NỘI DUNG BÀI HỌC
🔆Ưu điểm của mô hình Page Object (POM - đối tượng trang)
✅Phân tích code khi không dùng mô hình Page Object
✅Triển khai sử dụng Page Object Model
✅Sơ lược về Page Object Model (POM)
Page Object Model là một design pattern (mẫu thiết kế) giúp mô hình hóa các pages, hoặc các phần (component: header, footer, menu…) trong page của trang web hoặc màn hình mobile thành mỗi đối tượng riêng biệt. Mỗi component / page sẽ gói gọn tất cả các hành động và các thuộc tính của component / page đó.Page Object Model sử dụng các tính chất về Hướng đối tượng OOP (Object-Oriented Programming) trong ngôn ngữ lập trình để triển khai.
OOP nhìn tất cả mọi vật là Object – đối tượng. Đối tượng được định nghĩa trong các class có các thuộc tính và các hành động khác nhau. (biến, hàm, khối)
🔆Ưu điểm của mô hình Page Object (POM - đối tượng trang)
- Giúp bảo trì dễ dàng: POM hữu ích khi có sự thay đổi trong phần tử giao diện người dùng hoặc có sự thay đổi trong một hành động. Ví dụ: một menu thả xuống được thay đổi thành một nút radio. Trong trường hợp này, POM giúp xác định trang hoặc màn hình cần sửa đổi. Vì mọi màn hình sẽ có các tệp java khác nhau, nên việc xác định này là cần thiết để thực hiện các thay đổi bắt buộc đối với các tệp đó. Điều này làm cho các trường hợp kiểm thử dễ bảo trì và giảm lỗi.
- Giúp sử dụng lại mã (dùng lại code đã viết): Như đã thảo luận, tất cả các màn hình đều độc lập. Bằng cách sử dụng POM, người ta có thể sử dụng mã thử nghiệm cho một màn hình và sử dụng lại nó trong một trường hợp thử nghiệm khác. Không cần phải viết lại mã, do đó tiết kiệm thời gian và công sức.
- Dễ đọc code: Khi tất cả các màn hình có các tệp java độc lập, người ta có thể dễ dàng xác định các hành động sẽ được thực hiện trên một màn hình cụ thể bằng cách điều hướng qua tệp java đó thôi. Nếu một thay đổi ảnh hưởng đến một phần mã code nhất định thì nó có thể được thực hiện một cách hiệu quả mà không ảnh hưởng đến các tệp (class/package) khác.
- Tạo kho lưu trữ: Có thể một kho lưu trữ duy nhất cho các xử lý chung hoặc hoạt động chung cho các trang thay vì có các xử lý này nằm rải rác trong các test case riêng lẻ. VD: getTitlePage(), verifyHeaderPage(),...
✅Phân tích code khi không dùng mô hình Page Object
Trước tiên, hãy xem xét một ví dụ, điển hình của code auto KHÔNG sử dụng đối tượng trang (POM):
Login Test của Taurus App
import com.anhtester.common.BaseTest;
import com.anhtester.common.BaseTestTaurusApp;
import com.anhtester.drivers.DriverManager;
import com.anhtester.keywords.MobileUI;
import io.appium.java_client.AppiumBy;
import org.openqa.selenium.WebElement;
import org.testng.annotations.Test;
public class LoginTest_TaurusApp extends BaseTestTaurusApp {
@Test(priority = 1)
public void testLoginSuccess() {
MobileUI.sleep(2);
WebElement inputUsername = DriverManager.getDriver().findElement(AppiumBy.xpath("//android.view.View[@content-desc=\"Mobile App Flutter Beta\"]/following-sibling::android.widget.EditText[1]"));
inputUsername.click();
inputUsername.sendKeys("admin");
MobileUI.sleep(1);
WebElement inputPassword = DriverManager.getDriver().findElement(AppiumBy.xpath("//android.view.View[@content-desc=\"Mobile App Flutter Beta\"]/following-sibling::android.widget.EditText[2]"));
inputPassword.click();
inputPassword.sendKeys("admin");
DriverManager.getDriver().findElement(AppiumBy.xpath("//android.widget.Button[@content-desc=\"Sign in\"]")).click();
MobileUI.sleep(1);
WebElement menuMenu = DriverManager.getDriver().findElement(AppiumBy.accessibilityId("Menu"));
if (menuMenu.isDisplayed()) {
System.out.println("Login success.");
} else {
System.out.println("Login failed.");
}
}
}
❌Có hai vấn đề chính với cách tiếp cận này:
- Không có sự tách biệt giữa các hàm automation và bộ định vị trên UI (Locators trong ví dụ này). Cả các đối tượng element (id, xpath, name,...) và cách xử lý (click, sendKeys,..) cùng chung trong test cases. Nếu giao diện người dùng của AUT thay đổi element, bố cục hoặc cách nhập và xử lý thông tin đăng nhập, thì tại các test case phải thay đổi đồng loạt. Rất mất thời gian và không hiệu quả. Nhiều khi nó sai sót sửa đi sửa lại hoài. (cái này chỉ Login thôi đó, còn các trang khác nữa thì càng phức tạp)
- Locators bị trải rộng trong nhiều test cases, mà tất cả các test cases khác phải sử dụng test case đăng nhập này. Nó mà sai gì đó thì ngồi sửa hơi cực.
Nếu KHÔNG sử dụng mô hình Page Object Model (POM), bạn có thể gặp phải nhiều vấn đề nghiêm trọng, bao gồm:
1. Mã lặp lại và khó bảo trì
-
Mỗi test case có thể phải khai báo lại cùng một locator và logic tương tác với các phần tử UI.
-
Nếu UI thay đổi, bạn sẽ phải cập nhật nhiều nơi trong mã nguồn, dễ gây lỗi và mất thời gian.
2. Test case không rõ ràng, khó đọc
-
Khi tất cả logic điều khiển UI nằm trực tiếp trong test script, các test case sẽ trở nên rối rắm và khó đọc.
-
Rất khó để hiểu nhanh ý nghĩa của một test case nếu nó bị trộn lẫn với các lệnh điều khiển UI.
3. Thiếu tính tái sử dụng
-
Nếu không có POM, bạn không thể tái sử dụng code cho các màn hình khác nhau.
-
Điều này dẫn đến việc phải viết lại các thao tác tương tự nhiều lần.
4. Khó bảo trì khi ứng dụng thay đổi
-
Khi UI thay đổi, bạn phải sửa tất cả các test case có liên quan, thay vì chỉ sửa một nơi như trong POM.
-
Điều này làm tăng thời gian bảo trì và dễ gây lỗi do quên cập nhật toàn bộ test.
5. Khó mở rộng dự án
-
Khi dự án lớn lên, số lượng test case nhiều hơn, việc không có POM sẽ làm test framework trở nên hỗn loạn và khó mở rộng.
-
Các team mới hoặc tester mới khó có thể hiểu và tiếp tục phát triển dự án.
✅Triển khai sử dụng Page Object Model
POM giúp tổ chức mã nguồn theo nguyên tắc Separation of Concerns (SoC), nghĩa là:
-
Page Class: Chứa các phương thức và locator tương ứng với một màn hình cụ thể.
-
Test Class: Chỉ gọi các phương thức từ Page Class, giúp test case rõ ràng và dễ hiểu.
![[Appium Java] Bài 17 - Cấu trúc code theo Page Object Model (POM) - Page Factory](/uploads/lesson/selenium_java/pom/pom_pattern_anhtester.png)
- Property là biến lưu Locators, biến data cho từng trang
- Function là các hàm xử lý trong nội bộ từng trang như enterEmail(), clickLoginButton(),...
✳️Tạo cấu trúc source code theo POM như sau:
Áp dụng kỹ thuật POM, chúng ta sẽ viết lại trang Login trên theo mô hình Page Object. Chúng ta sẽ triển khai theo hướng mô hình Page Factory (phần mở rộng của Page Object).
Page Factory là gì?
Page Factory là một mô hình thiết kế (Design Pattern) trong Selenium/Appium, giúp quản lý các phần tử trên UI một cách hiệu quả bằng cách sử dụng Annotations (@FindBy
, @AndroidFindBy
, @iOSXCUITFindBy
).
Lợi ích:
✅ Code sạch hơn, không cần viết lại driver.findElement(...)
nhiều lần.
✅ Tăng tốc hiệu suất, vì các phần tử được khởi tạo một lần duy nhất.
✅ Hỗ trợ cả Android và iOS, dễ dàng dùng chung một Page Object cho cả hai nền tảng.
🔆Triển khai code theo mô hình Page Factory
Tạo 2 package pages và testcases như hình.
1. Tạo Page class (LoginPage.java)
package com.anhtester.Bai17_PageObjectModel.pages;
import com.anhtester.drivers.DriverManager;
import io.appium.java_client.pagefactory.AndroidFindBy;
import io.appium.java_client.pagefactory.AppiumFieldDecorator;
import io.appium.java_client.pagefactory.iOSXCUITFindBy;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.PageFactory;
import org.testng.Assert;
public class LoginPage {
// Constructor
public LoginPage() {
PageFactory.initElements(new AppiumFieldDecorator(DriverManager.getDriver()), this);
}
//Element/Locators thuộc chính trang này (màn hình này)
@AndroidFindBy(xpath = "//android.view.View[@content-desc=\"Mobile App Flutter Beta\"]/following-sibling::android.widget.EditText[1]")
@iOSXCUITFindBy(accessibility = "username")
private WebElement usernameField;
@AndroidFindBy(xpath = "//android.view.View[@content-desc=\"Mobile App Flutter Beta\"]/following-sibling::android.widget.EditText[2]")
@iOSXCUITFindBy(accessibility = "password")
private WebElement passwordField;
@AndroidFindBy(xpath = "//android.widget.Button[@content-desc=\"Sign in\"]")
@iOSXCUITFindBy(id = "loginBtn")
private WebElement loginButton;
@AndroidFindBy(accessibility = " Invalid email or password")
@iOSXCUITFindBy(accessibility = " Invalid email or password")
private WebElement errorMessage;
@AndroidFindBy(accessibility = "Menu")
@iOSXCUITFindBy(accessibility = "Menu")
private WebElement menuHome;
//Các hàm xử lý trong chính nội bộ trang này (màn hình này)
public void login(String username, String password) {
usernameField.click();
usernameField.sendKeys(username);
passwordField.click();
passwordField.sendKeys(password);
loginButton.click();
}
public void verifyLoginSuccess() {
Assert.assertTrue(menuHome.isDisplayed(), "The Table page not display. (Menu not found)");
}
public void verifyLoginFail() {
Assert.assertTrue(errorMessage.isDisplayed(), "The error message not display.");
System.out.println(errorMessage.getAttribute("content-desc"));
Assert.assertEquals(errorMessage.getAttribute("content-desc"), " Invalid email or password", "The content of error message not display.");
}
}
- Đầu tiên bắt buộc phải khai báo hàm xây dựng cho từng page class, và dùng cú pháp sau:PageFactory.initElements(new AppiumFieldDecorator(DriverManager.getDriver()), this);
Để khởi tạo giá trị cho các đối tượng element bên dưới. Hàm initElements được Selenium hỗ trợ trực tiếp theo mô hình Page Factory.
- Tiếp theo là dùng @AndroidFindBy và @iOSXCUITFindBy để nó tự động tìm kiếm element cho từng nền tảng chỉ định đối với cùng một element khai báo.
Nếu không như vậy thì buộc khai báo 2 element khác nhau cho 2 nền tảng thì khá phức tạp. Nên Page Factory rất phù hợp cho auto test mobile này. (còn đối với auto test web có thể không cần, vì nó không chia 2 nền tảng)
- Cuối cùng là khai báo các hàm xử lý dành cho nội bộ trang đó (màn hình). Kết hợp với các hàm verify khi có cần.
2. Tạo Test class (LoginTest.java)
package com.anhtester.Bai17_PageObjectModel.testcases;
import com.anhtester.Bai17_PageObjectModel.pages.LoginPage;
import com.anhtester.common.BaseTestTaurusApp;
import org.testng.annotations.Test;
public class LoginTest extends BaseTestTaurusApp {
//Khai báo các đối tượng Page class liên quan
private LoginPage loginPage;
@Test
public void testLoginSuccess() {
//Khởi tạo đối tượng Page class
loginPage = new LoginPage();
//Gọi hàm từ Page class sử dụng
loginPage.login("admin", "admin");
loginPage.verifyLoginSuccess();
}
@Test
public void testLoginFailWithUsernameInvalid() {
//Khởi tạo đối tượng Page class
loginPage = new LoginPage();
//Gọi hàm từ Page class sử dụng
loginPage.login("admin123", "admin");
loginPage.verifyLoginFail();
}
}
Các class test cases luôn luôn extend class Base Test để phần khởi tạo được chạy trước mới đến test cases sau.
package com.anhtester.common;
import com.anhtester.drivers.DriverManager;
import com.anhtester.helpers.SystemHelpers;
import com.anhtester.keywords.MobileUI;
import io.appium.java_client.AppiumBy;
import io.appium.java_client.AppiumDriver;
import io.appium.java_client.android.options.UiAutomator2Options;
import io.appium.java_client.service.local.AppiumDriverLocalService;
import io.appium.java_client.service.local.AppiumServiceBuilder;
import io.appium.java_client.service.local.flags.GeneralServerFlag;
import org.openqa.selenium.WebElement;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.AfterSuite;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.BeforeSuite;
import java.net.MalformedURLException;
import java.net.URL;
import java.time.Duration;
public class BaseTestTaurusApp {
private AppiumDriverLocalService service;
private String HOST = "127.0.0.1";
private String PORT = "4723";
private int TIMEOUT_SERVICE = 60;
@BeforeSuite
public void runAppiumServer() {
//Kill process on port
SystemHelpers.killProcessOnPort("4723");
//Build the Appium service
AppiumServiceBuilder builder = new AppiumServiceBuilder();
builder.withIPAddress(HOST);
builder.usingPort(Integer.parseInt(PORT));
builder.withArgument(GeneralServerFlag.LOG_LEVEL, "info"); // Set log level (optional)
builder.withTimeout(Duration.ofSeconds(TIMEOUT_SERVICE));
//Start the server with the builder
service = AppiumDriverLocalService.buildService(builder);
service.start();
if (service.isRunning()) {
System.out.println("##### Appium server started on " + HOST + ":" + PORT);
} else {
System.out.println("Failed to start Appium server.");
}
}
@BeforeMethod
public void setUpDriver() {
AppiumDriver driver;
UiAutomator2Options options = new UiAutomator2Options();
System.out.println("***SERVER ADDRESS: " + HOST);
System.out.println("***SERVER POST: " + PORT);
options.setPlatformName("Android");
options.setPlatformVersion("14");
options.setAutomationName("UiAutomator2");
options.setDeviceName("Pixel_9_Pro_XL_API_34");
options.setAppPackage("com.anhtester.mobile_app.taurus");
options.setAppActivity("com.anhtester.mobile_app.taurus.MainActivity");
options.setNoReset(false);
options.setFullReset(false);
try {
driver = new AppiumDriver(new URL("http://" + HOST + ":" + PORT), options);
DriverManager.setDriver(driver);
} catch (MalformedURLException e) {
throw new RuntimeException(e);
}
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(20));
}
@AfterMethod
public void tearDownDriver() {
if (DriverManager.getDriver() != null) {
DriverManager.quitDriver();
System.out.println("##### Driver quit and removed.");
}
}
@AfterSuite
public void stopAppiumServer() {
if (service != null && service.isRunning()) {
service.stop();
System.out.println("##### Appium server stopped.");
}
}
public void loginTaurusApp() {
MobileUI.sleep(2);
WebElement inputUsername = DriverManager.getDriver().findElement(AppiumBy.xpath("//android.view.View[@content-desc=\"Mobile App Flutter Beta\"]/following-sibling::android.widget.EditText[1]"));
inputUsername.click();
inputUsername.sendKeys("admin");
MobileUI.sleep(1);
WebElement inputPassword = DriverManager.getDriver().findElement(AppiumBy.xpath("//android.view.View[@content-desc=\"Mobile App Flutter Beta\"]/following-sibling::android.widget.EditText[2]"));
inputPassword.click();
inputPassword.sendKeys("admin");
DriverManager.getDriver().findElement(AppiumBy.xpath("//android.widget.Button[@content-desc=\"Sign in\"]")).click();
MobileUI.sleep(2);
WebElement menuMenu = DriverManager.getDriver().findElement(AppiumBy.accessibilityId("Menu"));
if (menuMenu.isDisplayed()) {
System.out.println("Login success.");
} else {
System.out.println("Login failed.");
}
}
public void downloadDataFromServer(int dataNumber) {
//Navigate to config to download database demo
DriverManager.getDriver().findElement(AppiumBy.accessibilityId("Config")).click();
DriverManager.getDriver().findElement(AppiumBy.accessibilityId("Server database")).click();
MobileUI.sleep(2);
DriverManager.getDriver().findElement(AppiumBy.xpath("//android.view.View[contains(@content-desc,'Data " + dataNumber + "')]/android.widget.Button")).click();
DriverManager.getDriver().findElement(AppiumBy.accessibilityId("Replace")).click();
MobileUI.sleep(1);
//Handle Alert Message, check displayed hoặc getText/getAttribute để kiểm tra nội dung message
if (DriverManager.getDriver().findElement(AppiumBy.accessibilityId("Downloaded")).isDisplayed()) {
System.out.println("Database demo downloaded.");
} else {
System.out.println("Warning!! Can not download Database demo.");
}
MobileUI.sleep(2);
}
}
Theo mô hình Page Object thì tại các class test cases sẽ KHÔNG còn viết code chay findElement và không xuất hiện driver nữa.
Thay vào đó là chỉ cần gọi các hàm đã khai báo tại class Page sử dụng lại.
Cái hay và ý nghĩa chính là chỉ cần khai báo các hàm xử lý từng class page một lần, dùng cho tất cả các vị trí test cases khác nếu cần đến.
🔆Triển khai Page Factory cho Menu page
Tạo class MenuPage trong package pages
package com.anhtester.Bai17_PageObjectModel.pages;
import com.anhtester.drivers.DriverManager;
import io.appium.java_client.pagefactory.AndroidFindBy;
import io.appium.java_client.pagefactory.AppiumFieldDecorator;
import io.appium.java_client.pagefactory.iOSXCUITFindBy;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.PageFactory;
import org.testng.Assert;
import java.util.List;
public class MenuPage {
// Constructor
public MenuPage() {
PageFactory.initElements(new AppiumFieldDecorator(DriverManager.getDriver()), this);
}
@AndroidFindBy(accessibility = "Menu")
@iOSXCUITFindBy(accessibility = "Menu")
private WebElement menuHome;
//Element/Locators thuộc chính trang này (màn hình này)
@AndroidFindBy(xpath = "//android.widget.EditText")
@iOSXCUITFindBy(accessibility = "")
private WebElement inputSearch;
@AndroidFindBy(xpath = "(//android.view.View[contains(@content-desc,\"Table\")])[1]")
@iOSXCUITFindBy(accessibility = "")
private WebElement firstItemTable;
@AndroidFindBy(xpath = "//android.view.View[contains(@content-desc,\"Table\")]")
@iOSXCUITFindBy(xpath = "")
private List<WebElement> listItemTable;
public void searchTable(String tableName) {
menuHome.click();
inputSearch.click();
inputSearch.sendKeys(tableName);
}
public void checkTableResultTotal(int expectedTotal) {
List<WebElement> listTables = listItemTable;
System.out.println("Table total: " + listTables.size());
Assert.assertTrue(listTables.size() >= expectedTotal);
}
}
Tạo class MenuTest trong package testcases để viết các test cases trong nội bộ trang Menu (table).
package com.anhtester.Bai17_PageObjectModel.testcases;
import com.anhtester.Bai17_PageObjectModel.pages.LoginPage;
import com.anhtester.Bai17_PageObjectModel.pages.MenuPage;
import com.anhtester.common.BaseTestTaurusApp;
import org.testng.annotations.Test;
public class MenuTest extends BaseTestTaurusApp {
LoginPage loginPage;
MenuPage menuPage;
@Test
public void testSearchTable() {
loginPage = new LoginPage();
loginPage.login("admin", "admin");
loginPage.verifyLoginSuccess();
downloadDataFromServer(2);
menuPage = new MenuPage();
menuPage.searchTable("Table 1");
menuPage.checkTableResultTotal(1);
}
}