bài viết script mobile đầu tiên, cả locator, action và assertion vẫn nằm trong một file test. Với một case thì nhìn rất hiền. Tới khoảng ba mươi case, cùng một locator xuất hiện ở bảy file và dev đổi ID một lần, bạn sẽ bắt đầu chuyến du lịch vòng quanh project để sửa từng chỗ.

Page Object Model giải quyết đúng chuyện đó: một màn hình phải có một nơi chịu trách nhiệm về locator và hành vi của nó. Với Robot Framework, nơi đó thường là resource file. Không có luật nào bắt bạn phải viết class Python chỉ để được gọi là POM.

Sơ đồ cây thư mục và đường đi trách nhiệm trong Page Object Model cho Robot Framework
Sơ đồ cây thư mục và đường đi trách nhiệm trong Page Object Model cho Robot Framework

Sơ đồ mình dựng từ đúng cấu trúc project trong bài: test giữ flow, page giữ locator và hành vi, còn app resource giữ vòng đời session.

Vấn đề của script đang trộn mọi thứ

*** Test Cases ***
Tìm Kiếm Với Nội Dung Đã Nhập
    Open Application    http://127.0.0.1:4723
    ...    platformName=Android
    ...    automationName=UiAutomator2
    ...    app=${CURDIR}/../demoapp/ApiDemos-debug.apk
    ...    appActivity=.app.SearchInvoke
    Input Text
    ...    id=io.appium.android.apis:id/txt_query_prefill
    ...    Robot Framework
    Click Element    id=io.appium.android.apis:id/btn_start_search
    Wait Until Page Contains Element    id=android:id/search_src_text
    Element Text Should Be
    ...    id=android:id/search_src_text
    ...    Robot Framework
    Close Application

Test này chạy được, nhưng nó đang làm bốn việc:

Nếu ID của ô nhập đổi, test case phải sửa. Nếu chuyển từ AVD sang USB, test case phải sửa. Nếu thêm case mới, lại copy đống mở app. Đây không phải vấn đề syntax; đây là vấn đề trách nhiệm bị trộn.

Cấu trúc sau khi tách POM

robot-android/
├── demoapp/
│   └── ApiDemos-debug.apk
├── resources/
│   ├── app.resource
│   └── pages/
│       ├── home_page.resource
│       └── search_page.resource
├── tests/
│   └── search.robot
├── variables/
│   └── android.py
└── requirements.txt
Test case: mục tiêukiểm thửHome Page: điều hướngSearch Page: nhậpkiểm traApp resource: sessionAppiumLibrary
// Mermaid diagram

Mũi tên thể hiện tầng trên sử dụng tầng dưới. app.resource không được import ngược page; nếu không dependency sẽ quay vòng và project bắt đầu có mùi.

Variable file: chỉ lo cấu hình target

variables/android.py giữ nguyên ý tưởng ở bài trước, nhưng tính đường dẫn APK từ vị trí của chính file Python. Nhờ vậy, đường dẫn không phụ thuộc current working directory ngẫu nhiên:

import os
from pathlib import Path


def get_variables():
    project_root = Path(__file__).resolve().parents[1]
    target = os.getenv("ANDROID_TARGET", "avd")

    capabilities = {
        "platformName": "Android",
        "automationName": "UiAutomator2",
        "app": str(project_root / "demoapp" / "ApiDemos-debug.apk"),
        "appPackage": "io.appium.android.apis",
        "appActivity": ".ApiDemos",
        "autoGrantPermissions": True,
    }

    if target == "usb":
        udid = os.getenv("ANDROID_UDID")
        if not udid:
            raise ValueError("ANDROID_UDID is required for USB execution")
        capabilities["udid"] = udid
    elif target == "avd":
        capabilities["avd"] = os.getenv("ANDROID_AVD", "Pixel_7_API_35")
    else:
        raise ValueError(f"Unsupported ANDROID_TARGET: {target}")

    return {
        "APPIUM_URL": os.getenv("APPIUM_URL", "http://127.0.0.1:4723"),
        "CAPABILITIES": capabilities,
    }

Path(__file__).resolve().parents[1] lấy project root từ chính vị trí file android.py. Như vậy chạy command ở đâu không còn làm đường dẫn APK trôi theo đó.

Variable file không chứa locator màn hình và cũng không mở session. Nó chỉ trả cấu hình. Một file biết ít nhưng biết đúng việc còn dễ sống hơn file common_everything_final.py biết cả vũ trụ.

App resource: sở hữu vòng đời session

Tạo resources/app.resource:

*** Settings ***
Library      AppiumLibrary
Variables    variables/android.py

*** Keywords ***
Mở Ứng Dụng ApiDemos
    Open Application
    ...    ${APPIUM_URL}
    ...    appium:options=${CAPABILITIES}

Đóng Tất Cả Appium Session
    Close All Applications

Chụp Bằng Chứng Khi Test Fail
    Run Keyword If Test Failed
    ...    Capture Page Screenshot
    ...    failed-${TEST NAME}.png

Mở Ứng Dụng ApiDemos che toàn bộ capability khỏi page và test. Keyword teardown chụp bằng chứng trước khi session bị đóng, vì đóng xong mới chụp thì Appium chỉ có thể chụp lại nỗi buồn.

Chụp Bằng Chứng Khi Test Fail phải được gọi bằng Test Teardown, vì chỉ ở scope đó Robot Framework mới có trạng thái pass/fail của test hiện tại. Việc đóng session dùng Suite Teardown riêng.

Home Page: chỉ biết màn hình Home

Tạo resources/pages/home_page.resource:

*** Settings ***
Library    AppiumLibrary

*** Variables ***
${HOME_TITLE}       accessibility_id=API Demos
${APP_MENU_ITEM}    accessibility_id=App
${SEARCH_MENU_ITEM}    accessibility_id=Search

*** Keywords ***
Home Page Phải Hiển Thị
    Wait Until Page Contains Element    ${APP_MENU_ITEM}    timeout=10s

Mở Màn Hình Search
    Home Page Phải Hiển Thị
    Click Element    ${APP_MENU_ITEM}
    Wait Until Page Contains Element    ${SEARCH_MENU_ITEM}    timeout=10s
    Click Element    ${SEARCH_MENU_ITEM}

Page giữ locator ở section Variables và chỉ đưa ra keyword có nghĩa với người đọc. Test case không cần biết App đang được tìm bằng accessibility id hay resource-id.

Biến ${HOME_TITLE} được khai báo nhưng chưa dùng thì nên xóa, hoặc bổ sung assertion thật sự cần nó. Mình cố ý để nó trong ví dụ để chỉ ra một bệnh phổ biến của POM: tích locator như tích đồ trong kho. Locator không phục vụ keyword nào chỉ làm người sau tưởng nó quan trọng.

Phiên bản gọn đúng hơn:

*** Variables ***
${APP_MENU_ITEM}       accessibility_id=App
${SEARCH_MENU_ITEM}    accessibility_id=Search

Search Page: sở hữu input, button và kết quả

Tạo resources/pages/search_page.resource:

*** Settings ***
Library    AppiumLibrary

*** Variables ***
${SEARCH_QUERY_INPUT}    id=io.appium.android.apis:id/txt_query_prefill
${SEARCH_SUBMIT_BUTTON}  id=io.appium.android.apis:id/btn_start_search
${SEARCH_RESULT_TEXT}    id=android:id/search_src_text

*** Keywords ***
Search Page Phải Hiển Thị
    Wait Until Page Contains Element
    ...    ${SEARCH_QUERY_INPUT}
    ...    timeout=10s

Nhập Nội Dung Tìm Kiếm
    [Arguments]    ${query}
    Search Page Phải Hiển Thị
    Clear Text    ${SEARCH_QUERY_INPUT}
    Input Text    ${SEARCH_QUERY_INPUT}    ${query}

Gửi Yêu Cầu Tìm Kiếm
    Click Element    ${SEARCH_SUBMIT_BUTTON}

Kết Quả Tìm Kiếm Phải Là
    [Arguments]    ${expected}
    Wait Until Page Contains Element
    ...    ${SEARCH_RESULT_TEXT}
    ...    timeout=10s
    Element Text Should Be    ${SEARCH_RESULT_TEXT}    ${expected}

Nhập Nội Dung Tìm Kiếm không chỉ gọi Input Text; nó bảo đảm page đã sẵn sàng và xóa dữ liệu cũ. Test không phải nhớ ba bước kỹ thuật đó.

Tuy nhiên page keyword cũng không nên phình thành một test case bí mật. Assertion Kết Quả Tìm Kiếm Phải Là ở đây thuộc page vì nó kiểm tra trạng thái trực tiếp của page; test vẫn là nơi quyết định giá trị kỳ vọng.

Test case sau khi refactor

Tạo tests/search.robot:

*** Settings ***
Resource          resources/app.resource
Resource          resources/pages/home_page.resource
Resource          resources/pages/search_page.resource
Suite Setup       Mở Ứng Dụng ApiDemos
Test Teardown     Chụp Bằng Chứng Khi Test Fail
Suite Teardown    Đóng Tất Cả Appium Session

*** Test Cases ***
Người Dùng Có Thể Tìm Kiếm Nội Dung
    [Tags]    smoke    android
    Mở Màn Hình Search
    Nhập Nội Dung Tìm Kiếm    Robot Framework
    Gửi Yêu Cầu Tìm Kiếm
    Kết Quả Tìm Kiếm Phải Là    Robot Framework

Bây giờ test case đọc đúng flow. Nó không biết Appium URL, serial, activity hay ID của button. Đó là mục tiêu của POM: đổi chi tiết giao diện ở một chỗ mà ý nghĩa test vẫn đứng yên.

Vì sao import không có ../?

Chạy từ project root:

robot --pythonpath . --outputdir results tests

--pythonpath . đưa project root vào đường tìm kiếm của Robot Framework. Nhờ đó test import resources/... nhất quán dù file suite nằm sâu hơn.

Trong VS Code với RobotCode, thêm vào .vscode/settings.json:

{
  "robotcode.robot.pythonPath": [
    "./"
  ]
}

Không cấu hình IDE thì terminal chạy được nhưng editor lại gạch đỏ resource. Code không hỏng; hai môi trường đang dùng hai search path khác nhau.

Tránh trùng tên keyword giữa các page

Nếu cả Home Page và Search Page đều có keyword Page Phải Hiển Thị, Robot Framework có thể báo ambiguous keyword. Có hai cách:

Home Page Phải Hiển Thị
Search Page Phải Hiển Thị

Hoặc gọi tên resource làm namespace:

home_page.Page Phải Hiển Thị
search_page.Page Phải Hiển Thị

Mình ưu tiên tên keyword đã mang ngữ cảnh cho hành vi public. Namespace hữu ích khi hai resource thực sự cần cùng tên, nhưng nếu dòng nào cũng phải prefix thì test bắt đầu giống gọi module hơn là đọc nghiệp vụ.

Thử thay locator mà không sửa test

Giả sử team Android đổi input sang accessibility id search-query. Chỉ sửa:

${SEARCH_QUERY_INPUT}    accessibility_id=search-query

File tests/search.robot giữ nguyên. Nếu bạn vẫn phải tìm và sửa năm test case, locator đã bị rò ra khỏi page object ở đâu đó.

POM không làm locator tự nhiên bền vững. Nó chỉ gom quyền sở hữu đúng chỗ. Một XPath mong manh đặt trong POM vẫn là XPath mong manh, chẳng qua nó mặc áo khoác kiến trúc nhìn nghiêm túc hơn thôi.

Nguồn tham khảo