ADB + Pytest + Allure 安卓应用自动化测试(进阶详细版)

目录

前言

一、功能说明

二、代码实现

三、效果示例

四、总结


前言

关于环境准备,可以先看下第一篇文章:ADB + Pytest 安卓自动化测试简单上手实践-CSDN博客

一、功能说明

ADB、Pytest、夜神模拟器自动化测试,实现页面点击跳转、弹窗交互与断言,优化代码结构清晰、工具模块封装通用,主要支持:

- 基础页面交互自动化测试

- 控制台/日志文件记录

- 失败/成功截图保存

- Allure 测试报告生成

二、代码实现

2.1 项目结构

android_auto_test/  
├── pages/				# 页面逻辑
├── testcases/   		# 测试用例
├── utils/  			# 工具方法
│ ├── adb_utils.py  	# ABD 操作
│ ├── log_utils.py  	# 日志
│ ├── decorators.py  	# 装饰器
│ └── wait_utils.py  	# 元素等待
│ └── ui_utils.py  		# UI 节点解析
├── allure-results/   	# 测试报告
├── logs/   			# 日志文件
├── screenshots/   	    # 截图
├── conftest.py  		# 全局 fixture
└── pytest.ini			# 配置

2.2 完整代码

pages:

# alert_dialog_page.py

from utils.log_utils import logger
from utils.wait_utils import wait_for_text_appear
from utils.adb_utils import click_by_text, get_element_bounds_by_text


class AlertDialogPage:
    """
    Alert Dialog 页面逻辑
    """

    def go_to_alert_dialogs(self):
        """
        操作页面路径::App 启动页 -> App -> Alert Dialogs
        """
        click_by_text("App")
        wait_for_text_appear("Alert Dialogs", timeout=5)
        click_by_text("Alert Dialogs")

    def click_ok_cancel_dialog(self):
        logger.debug("点击 'OK CANCEL DIALOG WITH A MESSAGE'")
        click_by_text("OK CANCEL DIALOG WITH A MESSAGE")

    def click_ok_button(self):
        logger.info("点击 OK 按钮")
        click_by_text("OK")

    def get_result_text(self):
        """
        获取 Alert Dialog 操作后的结果文本,例如 "LIST DIALOG"
        """
        nodes = get_element_bounds_by_text("LIST DIALOG")
        if not nodes:
            logger.warning("未找到结果文本")
            return ""
        return "LIST DIALOG"  # 可以进一步用 adb 截图 OCR 或使用 dump 判断文本内容

testcases:

# test_alert_dialog.py

"""
pytest 测试用例:调用页面对象和等待工具,验证 Alert Dialog 页面跳转与元素存在
"""

import allure
from utils.log_utils import logger
from utils.decorators import capture_failure
from utils.wait_utils import wait_for_text_appear
from pages.alert_dialog_page import AlertDialogPage


@allure.feature("Alert Dialog 测试")
@allure.story("跳转并验证页面文本")
@capture_failure()
def test_alert_dialog_navigation():
    """
    测试:启动页跳转到 Alert Dialog 页面,目标文本出现
    """
    page = AlertDialogPage()
    page.go_to_alert_dialogs()
    assert wait_for_text_appear("OK CANCEL DIALOG WITH A MESSAGE", timeout=5)
    logger.info("测试通过:页面跳转成功,目标文本出现")


@allure.feature("Alert Dialog 测试")
@allure.story("点击 OK 并验证结果")
@capture_failure()
def test_alert_dialog_click_ok():
    """
        测试:启动页跳转到 Alert Dialog 页面,目标文本点击交互
    """
    page = AlertDialogPage()
    page.go_to_alert_dialogs()
    page.click_ok_cancel_dialog()
    page.click_ok_button()
    assert wait_for_text_appear("LIST DIALOG", timeout=5)
    logger.info("测试通过:点击 OK 后正确")

utils:

# adb_utils.py

"""
ADB 工具:封装包括启动 App、点击文本等
"""

import re
import time
import subprocess

from utils.log_utils import logger
from utils.ui_utils import parse_ui_xml_nodes


def adb_shell(cmd: str) -> str:
    """
    执行 adb shell 命令并返回结果
    """
    full_cmd = f"adb shell {cmd}"
    result = subprocess.run(full_cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8")
    output = result.stdout.strip()
    logger.debug(f"[ADB SHELL] {cmd} -> {output[:100]}")  # 最多显示前 100 字符
    return output


def start_app(pkg: str, activity: str):
    """
    启动 App
    """
    logger.info(f"启动 App: {pkg}/{activity}")
    adb_shell(f"am start -n {pkg}/{activity}")
    time.sleep(1)


def restart_app(pkg: str, activity: str):
    """
    使用 -S 参数实现近似冷启动,避免模拟器因 force-stop 崩溃
    """
    logger.info(f"使用 -S 参数重启 App: {pkg}/{activity}")
    adb_shell(f"am start -S -n {pkg}/{activity}")
    time.sleep(1)


def stop_app(pkg: str):
    """
    关闭 App
    """
    logger.info(f"关闭 App: {pkg}")
    adb_shell(f"am force-stop {pkg}")


def click_at(x: int, y: int):
    """
    通过坐标点击屏幕
    """
    logger.debug(f"点击坐标: ({x}, {y})")
    adb_shell(f"input tap {x} {y}")


def get_element_bounds_by_text(target_text: str):
    """
    查找包含指定文本的 UI 元素,解析 bounds 获取中点坐标
    """
    for node in parse_ui_xml_nodes():
        if node.attrib.get("text") == target_text:
            bounds = node.attrib.get("bounds")
            match = re.findall(r"\[(\d+),(\d+)]", bounds)
            if match and len(match) == 2:
                x1, y1 = map(int, match[0])
                x2, y2 = map(int, match[1])
                return int((x1 + x2) / 2), int((y1 + y2) / 2)
    return None


def click_by_text(text: str):
    """
    通过文本查找并点击
    """
    coords = get_element_bounds_by_text(text)
    if coords:
        logger.info(f"点击文本: {text}")
        click_at(*coords)
    else:
        logger.error(f"未找到文本: {text}")
        raise ValueError(f"找不到文本: {text}")


def pull_file(remote: str, local: str):
    """
    从设备拉取文件到本地
    """
    logger.info(f"拉取文件: {remote} -> {local}")
    try:
        result = subprocess.run(["adb", "pull", remote, local],
                                capture_output=True, text=True, timeout=10)
        if result.returncode == 0:
            logger.info(f"拉取成功: {result.stdout.strip()}")
        else:
            logger.error(f"拉取失败: {result.stderr.strip()}")
    except Exception as e:
        logger.error(f"拉取异常: {e}")
# log_utils.py

"""
日志工具:统一日志格式与级别
"""

import os
import logging
from datetime import datetime
from utils.config import DEBUG_MODE
from rich.logging import RichHandler


def init_logger(name=__name__):
    """
    初始化日志器,支持控制台彩色和文件日志输出
    """
    logger = logging.getLogger(name)
    if not logger.handlers:
        logger.setLevel(logging.DEBUG if DEBUG_MODE else logging.INFO)

        # 控制台日志(Rich 彩色)
        ch = RichHandler(markup=True, show_time=True, show_level=True, show_path=False)
        cf = logging.Formatter("%(message)s")
        ch.setFormatter(cf)
        logger.addHandler(ch)

        # 文件日志(完整信息)
        os.makedirs("logs", exist_ok=True)
        log_file = f"logs/{datetime.now():%Y%m%d_%H%M%S}.log"
        fh = logging.FileHandler(log_file, encoding="utf-8")
        fh.setFormatter(logging.Formatter(
            "[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
        ))
        logger.addHandler(fh)

    return logger


# 全局日志对象
logger = init_logger()
# decorators.py

import os
import time
import functools
from utils.log_utils import logger
from utils.adb_utils import adb_shell, pull_file


def capture_failure(capture_success=False):
    """
    装饰器:用于测试用例,失败时自动截图并保存,也支持成功截图
    """
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            case_name = func.__name__   # 获取函数名作为用例名
            timestamp = time.strftime("%Y%m%d_%H%M%S")  # 当前时间戳
            file_prefix = f"{case_name}_{timestamp}"    # 构建文件名前缀
            remote_path = f"/sdcard/{file_prefix}.png"  # 设备中的截图保存路径
            local_dir = "screenshots"   # 本地保存路径
            local_path = os.path.join(local_dir, f"{file_prefix}.png")  # 本地保存完整路径

            try:
                result = func(*args, **kwargs)  # 执行原始测试函数

                if capture_success:
                    # 成功截图(可选)
                    adb_shell(f"screencap -p {remote_path}")
                    logger.info(f"测试通过,截图保存至设备: {remote_path}")
                    if not os.path.exists(local_dir):
                        os.makedirs(local_dir)
                    pull_file(remote_path, local_path)
                    logger.info(f"成功截图已拉取到本地: {local_path}")

                return result

            except Exception as e:
                # 捕获异常并截图
                logger.error(f"测试失败:{case_name} - {e}", exc_info=True)
                # 截图保存到设备
                adb_shell(f"screencap -p {remote_path}")
                logger.info(f"已截图保存至设备: {remote_path}")
                # 确保本地目录存在
                if not os.path.exists(local_dir):
                    os.makedirs(local_dir)
                # 拉取截图到本地
                pull_file(remote_path, local_path)
                logger.info(f"已拉取截图到本地: {local_path}")

                raise  # 继续抛出异常
        return wrapper
    return decorator
# wait_utils.py

"""
等待工具:封装动态轮询等待某文本出现
"""

import time
from utils.log_utils import logger
from utils.ui_utils import parse_ui_xml_nodes


def get_ui_texts():
    """
    获取当前 UI 所有可见文本内容(解析 uiautomator xml)
    """
    return [
        node.attrib.get("text")
        for node in parse_ui_xml_nodes()
        if node.attrib.get("text")
    ]


def wait_for_text_appear(target_text: str, timeout=10, interval=1):
    """
    等待某个文本出现在 UI 中
    """
    logger.debug(f"等待文本出现: {target_text}")
    start_time = time.time()
    while time.time() - start_time < timeout:
        texts = get_ui_texts()

        if target_text in texts:
            logger.debug(f"文本已出现: {target_text}")
            return True

        time.sleep(interval)

    logger.warning(f"等待超时,未找到文本: {target_text}")
    return False
# ui_utils.py

"""
UI 解析工具:封装从设备获取并解析 UI XML 节点方法
"""

import subprocess
from utils.log_utils import logger
import xml.etree.ElementTree as ET


def adb_shell(cmd: str) -> str:
    result = subprocess.run(f"adb shell {cmd}", shell=True,
                            stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8")
    return result.stdout.strip()


def parse_ui_xml_nodes():
    """
    获取并解析当前 UI 层级所有节点
    """
    adb_shell("uiautomator dump /sdcard/ui_dump.xml")
    xml_content = adb_shell("cat /sdcard/ui_dump.xml")
    try:
        tree = ET.fromstring(xml_content)
        return list(tree.iter("node"))
    except Exception as e:
        logger.error(f"解析 XML 失败: {e}")
        return []

conftest.py:

import pytest

from utils.adb_utils import restart_app
from utils.wait_utils import wait_for_text_appear

PACKAGE_NAME = "io.appium.android.apis"
LAUNCH_ACTIVITY = ".ApiDemos"


@pytest.fixture(scope="function", autouse=True)
def setup_app():
    """
    每个用例执行前通过 am start -S 重启 App(替代冷启动)
    """
    restart_app(PACKAGE_NAME, LAUNCH_ACTIVITY)

    # 等待首页稳定加载,比如“App”文本出现
    assert wait_for_text_appear("App", timeout=10), "首页未加载完成"

    yield

pytest.ini:

[pytest]
# addopts 是 pytest 的“额外命令行参数”,相当于你每次运行 pytest 都自动带上的参数
addopts = -v -s --alluredir=allure-results

# 这里告诉 pytest 只去 testcases 目录下查找测试文件和测试用例,而不是默认搜索当前目录或递归全部
testpaths = testcases

三、效果示例

控制台输出:

夜神模拟器 Demo App:

日志文件:

失败/成功截图:

Allure 测试报告:

四、总结

当前可以继续扩展测试用例设计,独立通用的页面逻辑,保持代码高耦合性,将复杂处理统一做封装,其他地方仅需调用即可,尽量做到代码简洁复用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Ohhhzm

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值