Appium 移动端自动化测试(十二):日志报告、依赖注入与框架设计

本篇是 Appium 系列的收官之作,讲解日志集成、测试报告生成、依赖注入,以及完整的框架设计。

一、日志集成

1.1 测试日志管理器

import logging
import os
from datetime import datetime

class AppiumTestLogger:
    """Appium 测试日志管理器"""

    def __init__(self, logger_name="appium_test", log_dir="logs"):
        self.logger = logging.getLogger(logger_name)
        self.logger.setLevel(logging.DEBUG)
        self.log_dir = log_dir

        if self.logger.handlers:
            return

        os.makedirs(log_dir, exist_ok=True)
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")

        # 控制台输出
        console = logging.StreamHandler()
        console.setLevel(logging.INFO)
        console.setFormatter(logging.Formatter(
            "%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S"
        ))

        # 文件输出
        file_handler = logging.FileHandler(
            f"{log_dir}/test_{timestamp}.log", encoding="utf-8"
        )
        file_handler.setLevel(logging.DEBUG)
        file_handler.setFormatter(logging.Formatter(
            "%(asctime)s [%(levelname)s] %(name)s - %(message)s"
        ))

        self.logger.addHandler(console)
        self.logger.addHandler(file_handler)

    def step(self, name, detail=""):
        msg = f"[STEP] {name}"
        if detail:
            msg += f" | {detail}"
        self.logger.info(msg)

    def device_info(self, driver):
        caps = driver.capabilities
        info = (
            f"设备: {caps.get('deviceName', 'N/A')} | "
            f"平台: {caps.get('platformName', 'N/A')} {caps.get('platformVersion', '')} | "
            f"UDID: {caps.get('udid', 'N/A')}"
        )
        self.logger.info(info)

    def assertion(self, expected, actual, passed=True):
        status = "PASS" if passed else "FAIL"
        self.logger.info(f"[ASSERT] {status} | Expected: {expected} | Actual: {actual}")

    def error(self, message):
        self.logger.error(f"[ERROR] {message}")

    def screenshot(self, filepath):
        self.logger.info(f"[SCREENSHOT] {filepath}")


logger = AppiumTestLogger()

1.2 Page 对象日志集成

class LoggedBasePage(BasePage):
    """带日志的 BasePage"""

    def click(self, locator):
        logger.step("点击", str(locator))
        try:
            super().click(locator)
        except Exception as e:
            logger.error(f"点击失败: {locator} | {e}")
            self.screenshot("click_error")
            raise

    def input_text(self, locator, text):
        logger.step("输入", f"{locator} = '{text}'")
        super().input_text(locator, text)

    def swipe_up(self, duration=300):
        logger.step("滑动", "向上")
        super().swipe_up(duration)

1.3 pytest 日志集成

# conftest.py
import pytest

@pytest.fixture(autouse=True)
def log_test_lifecycle(request, driver):
    """自动记录测试开始和结束"""
    test_name = request.node.name
    logger.step("TEST START", test_name)
    logger.device_info(driver)

    yield

    logger.step("TEST END", test_name)
    driver.save_screenshot(f"screenshots/{test_name}.png")

二、测试报告

2.1 Allure 报告(推荐)

pip install allure-pytest
brew install allure  # macOS
import allure
from appium.webdriver.common.appiumby import AppiumBy

@allure.feature("用户模块")
@allure.story("登录功能")
@allure.title("使用正确凭据登录")
@allure.severity(allure.severity_level.BLOCKER)
def test_login_success(driver):
    with allure.step("打开登录页面"):
        login = LoginPage(driver)

    with allure.step("输入用户名"):
        login.type_text(login.USERNAME, "admin")

    with allure.step("输入密码"):
        login.type_text(login.PASSWORD, "123456")

    with allure.step("点击登录"):
        home = login.login("admin", "123456")

    with allure.step("验证登录成功"):
        assert home.is_logged_in()

    # 附加截图
    allure.attach(
        driver.get_screenshot_as_png(),
        name="登录成功截图",
        attachment_type=allure.attachment_type.PNG
    )

    # 附加设备信息
    allure.attach(
        str(driver.capabilities),
        name="设备信息",
        attachment_type=allure.attachment_type.TEXT
    )

2.2 自定义报告生成器

import json
from datetime import datetime
from pathlib import Path

class AppiumTestReport:
    """Appium 测试报告生成器"""

    def __init__(self, output_dir="reports"):
        self.output_dir = Path(output_dir)
        self.output_dir.mkdir(exist_ok=True)
        self.results = []
        self.device_info = {}

    def set_device(self, driver):
        """记录设备信息"""
        caps = driver.capabilities
        self.device_info = {
            "device": caps.get("deviceName"),
            "platform": caps.get("platformName"),
            "version": caps.get("platformVersion"),
        }

    def add_result(self, name, status, duration, error=None, screenshot=None):
        """添加测试结果"""
        self.results.append({
            "name": name,
            "status": status,
            "duration": f"{duration:.2f}s",
            "device": self.device_info.get("device", "N/A"),
            "error": str(error) if error else None,
            "screenshot": screenshot,
            "timestamp": datetime.now().strftime("%H:%M:%S"),
        })

    def generate_html(self):
        """生成 HTML 报告"""
        total = len(self.results)
        passed = sum(1 for r in self.results if r["status"] == "pass")
        failed = sum(1 for r in self.results if r["status"] == "fail")

        html = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8">
<title>Appium 测试报告</title>
<style>
body {{ font-family: -apple-system, sans-serif; margin: 40px; background: #f5f5f5; }}
.summary {{ background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; }}
.device {{ color: #666; margin-bottom: 10px; }}
.stat {{ display: inline-block; padding: 8px 16px; margin-right: 8px; border-radius: 4px; color: white; }}
.pass {{ background: #22c55e; }} .fail {{ background: #ef4444; }}
table {{ width: 100%; border-collapse: collapse; background: white; border-radius: 8px; }}
th, td {{ padding: 10px; text-align: left; border-bottom: 1px solid #e5e7eb; }}
th {{ background: #f9fafb; }} .error {{ color: #ef4444; font-size: 12px; }}
</style></head><body>
<h1>Appium 移动端测试报告</h1>
<div class="device">
  设备: {self.device_info.get("device", "N/A")} |
  平台: {self.device_info.get("platform", "N/A")} {self.device_info.get("version", "")}
</div>
<div class="summary">
  <span class="stat pass">通过: {passed}</span>
  <span class="stat fail">失败: {failed}</span>
  <span>总计: {total} | 通过率: {passed/max(total,1)*100:.1f}%</span>
</div>
<table>
  <tr><th>用例</th><th>状态</th><th>设备</th><th>耗时</th><th>时间</th><th>错误</th></tr>"""
        for r in self.results:
            html += f"""
  <tr>
    <td>{r['name']}</td>
    <td class="{r['status']}">{r['status'].upper()}</td>
    <td>{r['device']}</td>
    <td>{r['duration']}</td>
    <td>{r['timestamp']}</td>
    <td class="error">{r['error'] or ''}</td>
  </tr>"""
        html += "</table></body></html>"

        report_path = self.output_dir / f"report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html"
        report_path.write_text(html, encoding="utf-8")
        return str(report_path)

三、依赖注入

3.1 测试上下文管理

class TestContext:
    """测试上下文:管理所有共享对象"""

    def __init__(self, config):
        self.config = config
        self._driver = None
        self._logger = None
        self._pages = {}
        self._report = None

    @property
    def driver(self):
        if self._driver is None:
            self._driver = self._create_driver()
        return self._driver

    @property
    def logger(self):
        if self._logger is None:
            self._logger = AppiumTestLogger()
        return self._logger

    @property
    def report(self):
        if self._report is None:
            self._report = AppiumTestReport()
        return self._report

    def get_page(self, page_class):
        if page_class not in self._pages:
            self._pages[page_class] = page_class(self.driver)
        return self._pages[page_class]

    def _create_driver(self):
        from appium import webdriver
        from appium.options.common.base import AppiumOptions

        options = AppiumOptions()
        options.load_capabilities(self.config["capabilities"])

        if self.config.get("remote_url"):
            return webdriver.Remote(self.config["remote_url"], options=options)
        else:
            return webdriver.Remote(
                f"http://127.0.0.1:{self.config.get('port', 4723)}",
                options=options
            )

    def cleanup(self):
        if self._driver:
            self._driver.quit()
            self._driver = None

3.2 pytest Fixture 依赖注入

# conftest.py
import pytest
import yaml

@pytest.fixture(scope="session")
def config():
    with open("config/config.yaml", "r", encoding="utf-8") as f:
        return yaml.safe_load(f)

@pytest.fixture(scope="session")
def report():
    return AppiumTestReport()

@pytest.fixture(scope="function")
def driver(config):
    from appium import webdriver
    from appium.options.common.base import AppiumOptions

    options = AppiumOptions()
    options.load_capabilities(config["capabilities"])
    options.load_capabilities({
        "appium:noReset": True,
        "appium:unicodeKeyboard": True,
        "appium:resetKeyboard": True,
    })

    server = config.get("remote_url", f"http://127.0.0.1:{config.get('port', 4723)}")
    driver = webdriver.Remote(server, options=options)
    driver.implicitly_wait(config.get("implicit_wait", 10))

    yield driver
    driver.quit()

@pytest.fixture
def login_page(driver):
    return LoginPage(driver)

@pytest.fixture
def home_page(driver):
    return HomePage(driver)

# 测试中使用
def test_login(driver, login_page, home_page, report):
    report.set_device(driver)
    start = time.time()
    try:
        login_page.login("admin", "123456")
        assert home_page.is_logged_in()
        report.add_result("test_login", "pass", time.time() - start)
    except Exception as e:
        report.add_result("test_login", "fail", time.time() - start, error=e)
        raise

3.3 配置文件管理

# config/config.yaml
# 基础配置
port: 4723
implicit_wait: 10
remote_url: null  # 本地运行

# Android 配置
android:
  capabilities:
    platformName: Android
    appium:deviceName: Pixel 6
    appium:automationName: UiAutomator2
    appium:app: /absolute/path/to/app.apk
    appium:udid: emulator-5554
    appium:noReset: true
    appium:unicodeKeyboard: true
    appium:resetKeyboard: true
    appium:newCommandTimeout: 120

# iOS 配置
ios:
  capabilities:
    platformName: iOS
    appium:deviceName: iPhone 15
    appium:automationName: XCUITest
    appium:app: /absolute/path/to/app.ipa
    appium:noReset: true
    appium:newCommandTimeout: 120

# BrowserStack 云测试
browserstack:
  remote_url: "https://hub-cloud.browserstack.com/wd/hub"
  capabilities:
    platformName: Android
    appium:deviceName: Samsung Galaxy S24
    appium:platformVersion: "14"
    appium:automationName: UiAutomator2
    appium:app: bs://app-hash
    bstack:options:
      userName: "${BS_USERNAME}"
      accessKey: "${BS_ACCESS_KEY}"
      project: My Project
      build: CI Build
      video: true
      networkLogs: true
# config_loader.py
import yaml
import os

class ConfigLoader:
    """配置加载器"""

    def __init__(self, config_dir="config"):
        self.config_dir = config_dir

    def load(self, env="local"):
        """加载配置"""
        with open(f"{self.config_dir}/config.yaml", "r", encoding="utf-8") as f:
            config = yaml.safe_load(f)

        # 环境变量覆盖
        if os.getenv("BROWSERSTACK_USERNAME"):
            config["remote_url"] = config["browserstack"]["remote_url"]
            config["capabilities"] = config["browserstack"]["capabilities"]
            config["capabilities"]["bstack:options"]["userName"] = os.getenv("BROWSERSTACK_USERNAME")
            config["capabilities"]["bstack:options"]["accessKey"] = os.getenv("BROWSERSTACK_ACCESS_KEY")

        if os.getenv("UDID"):
            config["capabilities"]["appium:udid"] = os.getenv("UDID")

        return config

四、CI/CD 框架集成

4.1 完整 CI 流程

代码提交 → 单元测试 → 编译 APK/IPA → 启动 Appium → 运行测试 → 生成报告 → 通知

4.2 GitHub Actions 完整配置

name: Appium Test Suite

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  appium-tests:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        include:
          - device: "Pixel 6"
            version: "14"
          - device: "Pixel 7"
            version: "13"

    steps:
      - uses: actions/checkout@v4

      - name: Setup
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dependencies
        run: |
          npm install -g appium
          appium driver install uiautomator2
          pip install -r requirements.txt
          pip install pytest allure-pytest

      - name: Start Appium
        run: appium -p 4723 &

      - name: Run tests
        run: |
          pytest tests/ \
            --alluredir=allure-results \
            -v \
            --device=${{ matrix.device }} \
            --platform-version=${{ matrix.version }}
        env:
          APPIUM_SERVER: "http://localhost:4723"
          APP_PATH: "/path/to/app.apk"

      - name: Generate report
        if: always()
        run: allure generate allure-results -o allure-report --clean

      - name: Upload report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: report-${{ matrix.device }}
          path: allure-report/

4.3 测试结果通知

class TestNotifier:
    """测试结果通知"""

    @staticmethod
    def send_webhook(webhook_url, results, device_info=""):
        """发送到钉钉/飞书 Webhook"""
        import requests

        total = len(results)
        passed = sum(1 for r in results if r["status"] == "pass")
        failed = total - passed
        color = "green" if failed == 0 else "red"

        message = (
            f"Appium 测试报告\n"
            f"设备: {device_info}\n"
            f"结果: {passed}/{total} 通过 ({passed/max(total,1)*100:.0f}%)\n"
        )
        if failed > 0:
            message += "失败用例:\n"
            for r in results:
                if r["status"] == "fail":
                    message += f"- {r['name']}: {r['error']}\n"

        requests.post(webhook_url, json={
            "msgtype": "text",
            "text": {"content": message}
        })

五、完整框架项目结构

appium-framework/
├── config/
│   ├── config.yaml              # 主配置
│   ├── android_caps.yaml        # Android 专用配置
│   └── ios_caps.yaml            # iOS 专用配置
├── pages/                       # Page Object
│   ├── __init__.py
│   ├── base_page.py             # BasePage
│   ├── cross_platform_page.py   # 跨平台 Page
│   ├── platform_locator.py      # 跨平台定位器
│   ├── login_page.py
│   ├── home_page.py
│   ├── search_page.py
│   └── components/
│       ├── bottom_nav.py
│       └── popup_handler.py
├── tests/                       # 测试用例
│   ├── conftest.py
│   ├── test_login.py
│   ├── test_search.py
│   └── test_profile.py
├── data/                        # 测试数据
│   ├── login_data.csv
│   ├── test_scenarios.yaml
│   └── test_data.json
├── utils/                       # 工具类
│   ├── logger.py
│   ├── report.py
│   ├── config_loader.py
│   ├── gesture_helper.py
│   ├── popup_handler.py
│   └── notifier.py
├── reports/                     # 测试报告
├── logs/                        # 日志
├── screenshots/                 # 截图
├── videos/                      # 录屏
├── requirements.txt
├── pytest.ini
└── README.md

六、系列总结

本系列 12 篇文章完整覆盖了 Appium 移动端自动化测试的知识体系:

篇章 主题
01 Appium 简介、环境搭建与架构原理
02 第一个测试脚本与元素定位策略
03 常用 Appium API 详解
04 高级元素定位技巧
05 等待机制与脚本优化
06 处理特殊情况(弹窗、Hybrid App、多上下文)
07 Appium 高级特性(手势、设备操作)
08 并行测试与 CI/CD 集成
09 问题排查与性能测试
10 Page Object Model 设计模式
11 数据驱动测试
12 日志报告、依赖注入与框架设计

掌握这些知识,你已经具备了构建企业级 Appium 移动端自动化测试框架的完整能力。

继续学习的方向:

  • 深入学习 Espresso(Android)和 XCUITest(iOS)原生测试框架
  • 探索 Appium 2.0 的新特性和插件系统
  • 结合 AI 辅助生成测试用例和维护定位器
  • 学习 Maestro、Detox 等新一代移动端测试工具
posted @ 2026-04-07 16:06  小小阿狸。  阅读(3)  评论(0)    收藏  举报