bài cài Appium và kết nối Android, mục tiêu của chúng ta chỉ là tạo được một Appium session và nhìn test pass. Bài này mới bắt đầu viết một test case có đầu có đuôi: mở màn hình Search của ApiDemos, nhập nội dung, gửi tìm kiếm và kiểm tra kết quả.

Nếu bạn đã quen SeleniumLibrary thì cảm giác ban đầu sẽ khá giống: vẫn locator, action, wait, assertion. Nhưng đừng bê nguyên tư duy browser sang mobile. Một ứng dụng native không có HTML, không có CSS selector và cũng chẳng quan tâm thẻ div của bạn đẹp đến đâu.

Appium Inspector hiển thị màn hình Android, XML hierarchy và locator của element đang chọn
Appium Inspector hiển thị màn hình Android, XML hierarchy và locator của element đang chọn

Ba vùng quan trọng nằm cạnh nhau: màn hình thiết bị, XML hierarchy và locator gợi ý. Ảnh: Appium Inspector (Apache-2.0). Ảnh dùng Android Settings để minh họa cơ chế; test trong bài vẫn chạy với ApiDemos.

“DOM mobile” thực ra là gì?

Dân automation thường gọi cây element trên mobile là DOM cho nhanh miệng. Nói chính xác hơn, với native Android, UiAutomator2 lấy một XML UI hierarchy từ accessibility và automation framework của Android. Appium trả hierarchy đó qua page source.

Một node có thể trông như sau:

<android.widget.EditText
    resource-id="io.appium.android.apis:id/txt_query_prefill"
    text=""
    clickable="true"
    enabled="true"
    bounds="[24,180][1056,312]" />

Đây không phải HTML. resource-id, content-desc, class, text, clickablebounds là dữ liệu Appium dùng để tìm hoặc tương tác với element. Khi màn hình đổi, page source cũng có thể đổi theo.

Một test case mobile gồm những phần nào?

Mở sessionĐưa app về đúng mànhìnhTìm elementThực hiện actionChờ trạng tháiAssertionĐóng session
// Mermaid diagram

Phần mở và đóng session là setup/teardown. Phần giữa mới là logic của test. Trộn cả bảy bước vào từng test case vẫn chạy, nhưng sau năm case bạn sẽ có năm cục capability giống nhau nằm chình ình trong project.

Tách cấu hình USB và AVD

Tạo variables/android.py:

import os

APPIUM_URL = os.getenv("APPIUM_URL", "http://127.0.0.1:4723")
ANDROID_TARGET = os.getenv("ANDROID_TARGET", "avd")
ANDROID_UDID = os.getenv("ANDROID_UDID", "")
ANDROID_AVD = os.getenv("ANDROID_AVD", "Pixel_7_API_35")
APK_PATH = os.path.abspath("demoapp/ApiDemos-debug.apk")


def get_variables():
    capabilities = {
        "platformName": "Android",
        "automationName": "UiAutomator2",
        "app": APK_PATH,
        "appPackage": "io.appium.android.apis",
        "appActivity": ".app.SearchInvoke",
        "autoGrantPermissions": True,
    }

    if ANDROID_TARGET == "usb":
        if not ANDROID_UDID:
            raise ValueError("ANDROID_UDID is required when ANDROID_TARGET=usb")
        capabilities["udid"] = ANDROID_UDID
    elif ANDROID_TARGET == "avd":
        capabilities["avd"] = ANDROID_AVD
    else:
        raise ValueError("ANDROID_TARGET must be 'usb' or 'avd'")

    return {
        "APPIUM_URL": APPIUM_URL,
        "CAPABILITIES": capabilities,
    }

Robot Framework gọi get_variables() khi import variable file. Dictionary ${CAPABILITIES} luôn có phần chung, sau đó mới bổ sung udid hoặc avd tùy target.

Với USB:

$env:ANDROID_TARGET = 'usb'
$env:ANDROID_UDID = 'SERIAL_TU_ADB_DEVICES'

Với AVD:

$env:ANDROID_TARGET = 'avd'
$env:ANDROID_AVD = 'Pixel_7_API_35'

Test case không phải chứa IF target là điện thoại... ELSE target là emulator.... Nó chỉ nhận một bộ capability đã hoàn chỉnh.

Script hoàn chỉnh

Tạo tests/search.robot:

*** Settings ***
Library         AppiumLibrary    timeout=10s
Variables       ../variables/android.py
Suite Setup     Mở ApiDemos
Suite Teardown  Close All Applications

*** Variables ***
${QUERY_INPUT}    id=io.appium.android.apis:id/txt_query_prefill
${SEARCH_BUTTON}  id=io.appium.android.apis:id/btn_start_search
${RESULT_TEXT}    id=android:id/search_src_text

*** Test Cases ***
Tìm Kiếm Với Nội Dung Đã Nhập
    [Tags]    smoke    android
    Input Text    ${QUERY_INPUT}    Robot Framework
    Click Element    ${SEARCH_BUTTON}
    Wait Until Page Contains Element    ${RESULT_TEXT}    timeout=10s
    Element Text Should Be    ${RESULT_TEXT}    Robot Framework
    Capture Page Screenshot    search-result.png

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

Suite Setup mở đúng một session cho suite. Suite Teardown đóng mọi session kể cả khi assertion fail. Trong dự án thật, có trường hợp cần tạo session mới cho từng test để bảo đảm isolation, nhưng đó là quyết định về dữ liệu và chi phí khởi động; không phải cứ thấy chữ best practice là đóng mở app liên hoàn như bật tắt đèn disco.

appium:options=${CAPABILITIES} gom capability vào dictionary. Cách này tránh một keyword dài hai chục dòng continuation khi capability tăng lên.

Locator nên chọn theo thứ tự nào?

Resource-id

Input Text    id=io.appium.android.apis:id/txt_query_prefill    Robot Framework

resource-id thường là lựa chọn đầu tiên vì nó do ứng dụng định danh và không phụ thuộc vị trí element trên màn hình. Nếu đội Android có thể bổ sung ID ổn định cho element cần test, hãy nói chuyện với họ trước khi viết một XPath dài bằng sớ Táo quân.

Accessibility id

Click Element    accessibility_id=App

Trên Android, accessibility id thường ánh xạ tới content-desc. Nó vừa thuận tiện cho automation, vừa cho thấy element đã có tên phục vụ accessibility. Tuy nhiên đừng tự động coi mọi text hiển thị là accessibility id; phải kiểm tra page source.

Android UIAutomator

Click Element
...    android=new UiSelector().text("App").className("android.widget.TextView")

Selector này chạy theo cơ chế Android và hữu ích khi không có ID nhưng có thuộc tính native đủ ổn định. Đổi ngôn ngữ ứng dụng là locator theo text có thể đi bụi, nên phải biết mình đang đánh đổi cái gì.

XPath

Click Element
...    xpath=//android.widget.TextView[@text="App"]

XPath không bị cấm. Nó chỉ thường chậm hơn và dễ gãy nếu bám vào cấu trúc cha-con hoặc index. XPath ngắn dựa trên thuộc tính rõ ràng còn đỡ; //android.widget.FrameLayout[1]/android.widget... kéo qua mười tầng thì giao diện chỉ cần hắt hơi là locator ngã.

Action, wait và assertion không phải một thứ

Click Element    ${SEARCH_BUTTON}
Wait Until Page Contains Element    ${RESULT_TEXT}    timeout=10s
Element Text Should Be    ${RESULT_TEXT}    Robot Framework

Nếu viết thế này:

Click Element    ${SEARCH_BUTTON}
Sleep    5s
Element Text Should Be    ${RESULT_TEXT}    Robot Framework

thì test luôn trả phí năm giây dù app phản hồi trong 200 ms, nhưng vẫn fail nếu máy chậm mất sáu giây. Sleep phù hợp khi bạn cố ý quan sát hoặc chờ một khoảng thời gian là chính yêu cầu; nó không phải wait strategy.

Lấy source khi đang viết locator

${source}=    Get Source
Log    ${source}

Get Source yêu cầu Appium lấy XML hierarchy hiện tại. Nó hữu ích để debug nhưng khá nặng; đừng nhét vào mọi keyword chỉ vì “biết đâu cần”. Khi một locator không tìm thấy, chụp screenshot và source tại đúng thời điểm lỗi sẽ có giá trị hơn việc đọc source từ ba bước trước.

AppiumLibrary mặc định chụp screenshot khi keyword fail. Có thể chủ động thêm:

Capture Page Screenshot    before-submit.png
${source}=    Get Source
Log    ${source}    level=DEBUG

Chạy và đọc kết quả

Chạy toàn bộ file:

robot --pythonpath . --outputdir results tests\search.robot

Chạy riêng tag smoke:

robot --pythonpath . --include smoke --outputdir results tests

Khi fail, đọc theo thứ tự:

  1. terminal để biết test và keyword nào fail;
  2. results\log.html để xem từng keyword, argument và screenshot;
  3. Appium server log để biết request đã tới driver chưa;
  4. adb logcat nếu Appium báo UiAutomator2 hoặc ứng dụng Android có vấn đề.

report.html cho bức tranh tổng, còn log.html mới là chỗ mổ xẻ. Nhìn mỗi dòng Element not found ở terminal rồi thay XPath ngẫu nhiên cũng giống sửa xe bằng cách đá từng cái lốp.

Nguồn tham khảo