nodejs appium 手机自动化
Appium + WebDriverIO + Jest 自动化测试环境搭建与实践指南 🧪
本文档旨在整理 Appium 自动化测试环境的搭建过程,包括所需软件安装、常见问题排查、基础脚本编写、元素定位方法以及使用 Jest 进行测试和进阶应用。
1. 环境安装与配置 🛠️
1.1 必备软件
-
Java JDK (推荐 18 或更高版本)
- 安装命令 (Debian/Ubuntu):
sudo apt-get update sudo apt-get install openjdk-18-jdk
- 验证安装:
java -version
- 安装命令 (Debian/Ubuntu):
-
Android SDK
- 安装 Android Studio 或独立的 SDK Command-line Tools。
- 重要: 配置环境变量
ANDROID_HOME
指向 SDK 的安装目录,并将platform-tools
和tools
(或cmdline-tools/latest/bin
) 添加到系统PATH
。 - 参考官方教程或搜索 “安装 Android SDK 教程”。
-
ADB (Android Debug Bridge)
- 通常随 Android SDK 的
platform-tools
一起安装。 - 验证安装:
adb version
- 通常随 Android SDK 的
-
Node.js (推荐 LTS 版本)
- 从 Node.js 官网 下载安装包或使用包管理器 (如
nvm
,apt
,brew
) 安装。 - 验证安装:
node -v
和npm -v
- 从 Node.js 官网 下载安装包或使用包管理器 (如
-
WebDriverIO
- 将在后续步骤中通过 npm 安装。
1.2 安装 Appium
-
使用 npm 全局安装 Appium 命令行工具:
npm i --location=global appium
- 注意:
--location=global
是较新 npm 版本推荐的替代-g
的方式。
- 注意:
-
测试是否安装成功:
appium --version # 或者直接启动服务 appium
1.3 安装 Appium 驱动 (以 UiAutomator2 为例)
- Appium 需要特定的驱动程序来与不同平台的 UI 框架交互。对于 Android,常用的是
UiAutomator2
。appium driver install uiautomator2
2. 安装过程中的常见问题与解决 🐛
2.1 PowerShell 执行策略问题 (Windows) PowerShell Script Execution Policy Issue (Windows) 윈도우 PowerShell 스크립트 실행 정책 문제 (Windows)
- 问题描述: 在 Windows PowerShell 中运行
appium
命令时报错:appium : 无法加载文件 C:\Users\yys53\AppData\Roaming\npm\appium.ps1,因为在此系统上禁止运行脚本。有关详细信息,请参阅 https:/go.microsoft.com/fwlink/?LinkID=135170 中的 about_Execution_Policies。
- 原因: Windows PowerShell 的执行策略 (Execution Policy) 默认设置为
Restricted
,禁止运行任何脚本。 - 解决方案 (推荐): 更改当前用户的执行策略。
- 以管理员身份 运行 PowerShell。
- 执行以下命令:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
RemoteSigned
: 允许本地脚本运行,远程脚本需签名。相对安全。CurrentUser
: 策略仅应用于当前用户。
- 当提示确认时,输入
A
并按 Enter。
- 总结: 修改执行策略后,即可正常运行
appium.ps1
脚本启动 Appium 服务。
2.2 Chromedriver 下载超时问题 Chromedriver Download Timeout Issue
- 问题描述: 安装
uiautomator2
驱动时报错,提示下载 Chromedriver 超时。Error: ✖ Encountered an error when installing package: npm command 'install ... appium-uiautomator2-driver --json' failed with code 1. STDOUT: { "error": { "code": 1, "summary": "command failed", "detail": "... Error installing Chromedriver: timeout of 15000ms exceeded ..." } } STDERR: npm ERR! ... AxiosError: timeout of 15000ms exceeded npm ERR! ... Downloading Chromedriver can be skipped by setting the'APPIUM_SKIP_CHROMEDRIVER_INSTALL' environment variable.
- 原因: 网络问题或默认的 15 秒超时时间不足以下载 Chromedriver。
- 解决方案:
- 增加超时时间: 设置环境变量
APPIUM_CHROMEDRIVER_INSTALL_TIMEOUT
(单位:毫秒),然后重试安装。- Linux/macOS:
export APPIUM_CHROMEDRIVER_INSTALL_TIMEOUT=60000 # 设置为 60 秒 appium driver install uiautomator2
- Windows (cmd):
set APPIUM_CHROMEDRIVER_INSTALL_TIMEOUT=60000 appium driver install uiautomator2
- Windows (PowerShell):
$env:APPIUM_CHROMEDRIVER_INSTALL_TIMEOUT="60000" appium driver install uiautomator2
- Linux/macOS:
- 跳过下载 (如果暂时不需要 Webview 测试): 设置环境变量
APPIUM_SKIP_CHROMEDRIVER_INSTALL
为true
,然后重试安装。- Linux/macOS:
export APPIUM_SKIP_CHROMEDRIVER_INSTALL=true appium driver install uiautomator2
- Windows (cmd):
set APPIUM_SKIP_CHROMEDRIVER_INSTALL=true appium driver install uiautomator2
- Windows (PowerShell):
$env:APPIUM_SKIP_CHROMEDRIVER_INSTALL="true" appium driver install uiautomator2
- Linux/macOS:
- 增加超时时间: 设置环境变量
2.3 npm 证书过期问题 (CERT_HAS_EXPIRED
) npm Certificate Expired Issue
- 问题描述: 使用 npm 安装包 (如
appium
) 时报错,提示证书过期。npm ERR! code CERT_HAS_EXPIRED npm ERR! errno CERT_HAS_EXPIRED npm ERR! request to https://registry.npm.taobao.org/appium failed, reason: certificate has expired
- 原因:
- 系统时间不正确。
- npm 配置的 registry (源) 使用的 SSL 证书确实已过期。
- 网络环境中有中间人干扰 SSL 验证。
- 解决方案:
- 检查并同步系统时间: 确保你的计算机时间是准确的。
- 切换回官方 npm 源 (或尝试其他可靠镜像):
npm config set registry https://registry.npmjs.org/
- (不推荐,有安全风险) 临时禁用 SSL 验证:
npm set strict-ssl false
npm set strict-ssl true
恢复 SSL 验证。 - 更新 Node.js 和 npm: 旧版本可能包含过时的证书信息。
2.4 npm 缓存权限问题 (EACCES
) npm Cache Permission Issue
- 问题描述: 运行
appium driver install
或其他 npm 命令时报错,提示缓存文件夹包含 root 用户拥有的文件。Error: npm command 'info appium-uiautomator2-driver ... --json' failed with code 243. STDOUT: { "error": { "code": "EACCES", "summary": "\nYour cache folder contains root-owned files...", "detail": "To permanently fix this problem, please run:\n sudo chown -R 501:20 \"/Users/yangyongsheng/.npm\"" } } STDERR: npm ERR! code EACCES npm ERR! syscall open npm ERR! path /Users/yangyongsheng/.npm/_cacache/... npm ERR! errno -13 npm ERR! Your cache folder contains root-owned files... npm ERR! To permanently fix this problem, please run: npm ERR! sudo chown -R 501:20 "/Users/yangyongsheng/.npm"
- 原因: 之前可能使用了
sudo
运行npm
命令,导致缓存目录~/.npm
的部分文件或文件夹所有者变成了 root 用户,而当前用户没有权限写入。错误信息中501:20
代表当前用户的 UID 和 GID。 - 解决方案: 按照错误提示,更改
~/.npm
目录及其所有内容的所有者为当前用户。- 注意: 命令中的
501:20
需要替换为你自己系统的用户 ID 和组 ID。通常可以直接使用当前用户名。可以在终端运行id -u
获取用户 ID,id -g
获取组 ID。或者更简单地:sudo chown -R $(whoami) ~/.npm
- 或者使用错误提示中给出的具体 ID (如果确定无误):
sudo chown -R 501:20 "/Users/yangyongsheng/.npm"
- 注意: 命令中的
3. 编写第一个 WebDriverIO 脚本 📝
3.1 示例代码 (控制微信) Example Code (Controlling WeChat)
// 引入 WebDriverIO 库中的 remote 函数
const { remote } = require('webdriverio');
// 微信应用的启动配置 (Desired Capabilities)
const capabilities = {
platformName: 'Android', // 指定平台为 Android
'appium:automationName': 'UiAutomator2', // 使用 UiAutomator2 自动化引擎
'appium:deviceName': 'Android', // 设备名 (可自定义)
'appium:appPackage': 'com.tencent.mm', // 微信应用的包名
'appium:appActivity': '.ui.LauncherUI', // 微信启动页的 Activity
'appium:noReset': true, // 保留应用数据和登录状态,不重置应用
'appium:unicodeKeyboard': false, // 建议设为 true,使用 Appium 输入法避免中文输入问题
'appium:resetKeyboard': false, // 建议设为 true, 配合 unicodeKeyboard 使用
'appium:udid': '192.168.31.41:5555', // 目标设备的唯一标识符 (通过 adb devices 获取)
};
// Appium 服务器配置
const wdOpts = {
hostname: process.env.APPIUM_HOST || 'localhost', // Appium 服务器的主机名 (通常是本机)
port: parseInt(process.env.APPIUM_PORT, 10) || 4723, // Appium 服务器的端口号
logLevel: 'info', // WebDriverIO 日志级别 (可选 'trace', 'debug', 'info', 'warn', 'error', 'silent')
capabilities, // 使用上面定义的应用配置
};
// 主函数 (入口)
async function main() {
// 创建 WebDriver 实例,连接到 Appium 服务器
const driver = await remote(wdOpts);
console.log('🚀 WebDriver session established!');
try {
// === 元素定位与操作 ===
// 等待 "发现" 按钮出现并点击 (使用 XPath)
console.log('🔍 Locating "发现" button...');
const findBtn = await driver.$('//android.widget.TextView[@text="发现"]');
await findBtn.waitForExist({ timeout: 15000 }); // 等待元素存在
await findBtn.waitForDisplayed({ timeout: 10000 }); // 等待元素可见
console.log('✅ "发现" button found!');
await findBtn.click();
console.log('🖱️ Clicked "发现" button');
// 示例:通过 resource-id 定位朋友圈入口并点击
// (注意: resource-id 可能随版本变化,请使用 Appium Inspector 确认)
console.log('🔍 Locating "朋友圈" entry...');
const momentsEntry = await driver.$('//android.widget.TextView[@text="朋友圈"]'); // 假设文本是 "朋友圈"
// 或者 const momentsEntry = await driver.$('android=new UiSelector().resourceId("com.tencent.mm:id/...")'); // 使用 resource-id
await momentsEntry.waitForExist({ timeout: 10000 });
await momentsEntry.waitForDisplayed({ timeout: 10000 });
console.log('✅ "朋友圈" entry found!');
// await momentsEntry.click(); // 示例点击
// console.log('🖱️ Clicked "朋友圈" entry');
// 示例:获取元素属性 (假设获取某个时间元素的文本)
// console.log('🔍 Locating time element...');
// const timeSel = await driver.$('//android.widget.ImageView[@resource-id="com.tencent.mm:id/huj"]'); // 需要用 Inspector 确认
// await timeSel.waitForExist({ timeout: 5000 });
// const timeText = await timeSel.getAttribute('text'); // 或 content-desc 等属性
// console.log(`🕒 Time element attribute: ${timeText}`);
// 示例:滑动操作 (从屏幕中心向上滑动半个屏幕)
// console.log('🖐️ Performing swipe action...');
// const { width, height } = await driver.getWindowSize();
// await driver.touchPerform([
// { action: 'press', options: { x: width / 2, y: height * 0.75 } },
// { action: 'wait', options: { ms: 500 } },
// { action: 'moveTo', options: { x: width / 2, y: height * 0.25 } },
// { action: 'release' }
// ]);
// console.log('🖐️ Swipe completed');
// 暂停 1 秒,观察效果
await driver.pause(1000);
// 示例:保存某个元素的截图 (如头像)
// console.log('📸 Taking screenshot of an element...');
// const item = await driver.$('...'); // 定位到包含头像的父元素或直接定位头像
// const avatarSel = await item.$('//android.widget.ImageView[@resource-id="com.tencent.mm:id/a27"]'); // 需要用 Inspector 确认
// await avatarSel.waitForExist({ timeout: 5000 });
// await avatarSel.saveScreenshot('./avatar.png');
// console.log('🖼️ Avatar screenshot saved to avatar.png');
} catch (err) {
console.error("❌ An error occurred during the test:", err);
// 可以在这里添加截图或记录更多错误信息的操作
// await driver.saveScreenshot('./error_screenshot.png');
} finally {
// 测试结束,等待 1 秒后关闭会话
console.log('⏳ Pausing before closing session...');
await driver.pause(1000);
if (driver) {
await driver.deleteSession();
console.log('✅ WebDriver session closed.');
}
}
}
// 运行主函数并捕获任何未处理的 Promise 拒绝
main().catch(console.error);
3.2 代码解释 Code Explanation
require('webdriverio')
: 引入 WebDriverIO 库。capabilities
: 定义测试目标设备和应用的信息,这是 Appium 服务器识别和启动应用的关键。platformName
: 'Android' 或 'iOS'。automationName
: 使用的自动化引擎,Android 通常是 'UiAutomator2',iOS 是 'XCUITest'。deviceName
: 任意设备名,或adb devices
显示的设备 ID。appPackage
&appActivity
: 安卓应用包名和启动 Activity,用于启动特定应用。可通过adb shell dumpsys window | findstr mCurrentFocus
(Windows) 或adb shell dumpsys window | grep mCurrentFocus
(Linux/Mac) 等命令获取当前前台应用的包名和 Activity。noReset
:true
表示不清空应用数据,false
表示每次启动都像新安装一样。unicodeKeyboard
&resetKeyboard
: 推荐都设为true
,以支持 Appium 输入中文和特殊字符,并在测试结束后恢复原始输入法。udid
: 必填,指定要操作的设备。
wdOpts
: 配置 WebDriverIO 客户端如何连接 Appium 服务器。hostname
: Appium 服务地址。port
: Appium 服务端口。logLevel
: 控制 WebDriverIO 输出的日志详细程度。
remote(wdOpts)
: 异步函数,根据配置建立与 Appium 服务器的连接,返回一个driver
对象。driver.$('XPATH or other selector')
: 查找单个元素。返回一个 Element 对象。支持多种选择器策略,如 XPath, Accessibility ID, UIAutomator (Android), Predicate String (iOS) 等。await element.waitForExist()
/await element.waitForDisplayed()
: 等待元素在 DOM 中出现或在屏幕上可见。await element.click()
: 点击元素。await element.getAttribute('attributeName')
: 获取元素的指定属性值 (如text
,content-desc
,resource-id
,checked
,selected
等)。await driver.pause(milliseconds)
: 暂停执行指定时间。await element.saveScreenshot(filePath)
: 将元素的截图保存到指定路径。await driver.touchPerform([...])
: 执行复杂的触摸手势,如滑动、长按、多点触控。await driver.deleteSession()
: 断开与 Appium 服务器的连接,结束测试会话。try...catch...finally
: 标准的错误处理结构,确保即使测试中出错,deleteSession
也能被执行,释放资源。
4. 元素定位 🔍
4.1 使用 Appium Inspector
- Appium Inspector 是一个独立的桌面应用,用于连接到 Appium 会话,查看应用的 UI 结构,并获取元素的定位符。
- 下载地址: Releases · appium/appium-inspector
- 使用方法:
- 启动 Appium 服务器 (
appium
命令)。 - 打开 Appium Inspector。
- 配置 "Desired Capabilities" (与脚本中的
capabilities
类似)。 - 点击 "Start Session"。
- Inspector 会在设备上启动应用,并显示应用界面的截图和 UI 元素层级树。
- 点击界面上的元素,右侧会显示该元素的属性 (如
resource-id
,text
,content-desc
,xpath
等),方便你选择合适的定位策略。
- 启动 Appium 服务器 (
4.2 JSON Representation 配置 (用于 Appium Inspector)
这是在 Appium Inspector 中配置 "Desired Capabilities" 的 JSON 格式示例:
{
"platformName": "Android",
"appium:automationName": "UiAutomator2",
"appium:appPackage": "com.eggflower.read", // 替换为你的 App 包名
"appium:appActivity": "com.dragon.read.pages.splash.SplashActivity", // 替换为你的 App 启动 Activity
"appium:noReset": true,
"appium:unicodeKeyboard": false, // 建议设为 true
"appium:resetKeyboard": false, // 建议设为 true
"appium:udid": "192.168.31.182:5555" // 替换为你的设备 UDID
}
4.3 (提及) 使用 lxml 解析 XML
- Appium/UiAutomator2 可以获取当前页面的 UI 结构 XML (
driver.getPageSource()
)。 - 如果需要复杂的离线分析或处理 UI 结构,可以使用 Python 的
lxml
库或其他 XML 解析库来解析这个 XML 字符串,并使用 XPath 进行查询。但在 WebDriverIO 脚本中,通常直接使用driver.$
或driver.$$
进行在线定位。
5. 使用 Jest 运行 Appium 测试 🚀
Jest 是一个流行的 JavaScript 测试框架。将其与 WebDriverIO 结合可以提供强大的测试运行、断言和报告功能。
5.1 遇到的 ESM 支持问题 ESM Support Issues Encountered
- 问题描述 1: 运行 Jest 时报错,提示需要支持 ES Modules 的 Node.js 版本。
You need to run with a version of node that supports ES Modules in the VM API. See https://jestjs.io/docs/ecmascript-modules
- 问题描述 2: (Node.js 20.x 版本偶现,18.x 较常见) Jest 进程直接崩溃退出,状态码
3221225477
(通常表示访问冲突或依赖问题)。RUNS ./ld.test.js node:child_process:960 throw err; ^ Error: Command failed: npx jest ld.test.js 5 --config D:/node/jest.config.js at checkExecSyncError (node:child_process:885:11) ... status: 3221225477, signal: null, ... }
- 原因:
- Jest 对 ES Modules (ESM) 的原生支持需要 Node.js 12.16.0+ 或 13.2.0+,并且需要显式开启实验性 VM 模块标志。
- Appium/WebDriverIO 或其依赖项可能混合使用了 CommonJS (CJS) 和 ESM,或者测试代码本身使用了 ESM 语法 (
import
/export
),导致 Jest 在默认配置下无法正确处理。 - 状态码
3221225477
(0xC0000005) 是 Windows 上的 "Access Violation" 错误,可能由多种原因引起,包括 Node.js 版本与依赖库 (特别是 C++ 插件) 的不兼容、内存问题、或者 ESM 加载过程中的内部错误。
5.2 解决方案:配置 Jest 以支持 ESM Solution: Configuring Jest for ESM Support
-
安装
cross-env
: 用于跨平台设置环境变量。npm install --save-dev cross-env
-
修改
package.json
中的scripts
:- 在运行 Jest 的命令前添加
cross-env NODE_OPTIONS='--experimental-vm-modules'
。 --runInBand
建议在调试 Appium 测试时使用,确保测试按顺序串行执行,避免资源冲突。
{ "scripts": { "test": "cross-env NODE_OPTIONS='--experimental-vm-modules' jest --runInBand", "test:coverage": "cross-env NODE_OPTIONS='--experimental-vm-modules' jest --runInBand --coverage" } // ... 其他配置 }
- 在运行 Jest 的命令前添加
-
Windows 环境下的
NODE_OPTIONS
:- 如果你不使用
cross-env
,在不同终端设置环境变量的方式不同:- cmd:
set NODE_OPTIONS=--experimental-vm-modules
- PowerShell:
$env:NODE_OPTIONS="--experimental-vm-modules"
- cmd:
- 注意: 直接在终端设置只对当前会话有效。写入
package.json
更方便。
- 如果你不使用
-
(可选) 使用 Babel 转译: 如果配置
NODE_OPTIONS
后仍有问题,或想确保更好的兼容性,可以使用 Babel 将 ESM 代码转译为 Jest 能理解的 CommonJS。- 安装依赖:
npm install --save-dev @babel/core @babel/preset-env babel-jest
- 创建
babel.config.js
文件:module.exports = { presets: [['@babel/preset-env', {targets: {node: 'current'}}]], };
- Jest 会自动检测并使用 Babel 进行转译。
- 安装依赖:
5.3 Node 版本问题与潜在错误 Node Version Issues & Potential Errors
- 关于
status: 3221225477
错误:- 此错误较为棘手,网上没有统一的完美解决方案。
- 尝试的策略:
- 更换 Node.js 版本: 尝试不同的 Node.js 18.x 或 20.x 的子版本,有时特定版本组合可能存在问题。
- 清理和重装依赖: 删除
node_modules
和package-lock.json
,然后运行npm install
。 - 更新 Jest 和相关依赖:
npm update jest @babel/core @babel/preset-env babel-jest webdriverio --save-dev
(或save
)。 - 检查系统环境: 确保没有其他软件冲突,系统资源充足。
- 临时方案: 如果 Jest 运行持续不稳定,可以像笔记中提到的,将 WebDriverIO 脚本单独运行 (不通过 Jest),或者尝试其他测试框架 (如 Mocha)。
6. 进阶:封装与多设备测试 ✨ Advanced: Abstraction & Multi-Device Testing
为了提高代码复用性和可维护性,可以将 Appium/WebDriverIO 的常用操作封装成工具类,并设计测试脚本以支持多设备并行或串行测试。
6.1 封装 MobileApp
工具类 (mobileAppUtils.js
)
下面的代码展示了一个封装了 Appium 连接、启动、元素查找、点击、截图、会话管理、ADB 连接、ATX Agent 卸载以及 Allure 报告生成的工具类。
// mobileAppUtils.js
const { remote } = require('webdriverio');
const { exec } = require('child_process');
const net = require('net');
const fs = require('fs');
const path = require('path');
// 全局配置 (可通过环境变量覆盖)
const config = {
// Appium 相关路径 (请根据你的实际安装位置修改)
appiumCmdPath: process.env.APPIUM_CMD_PATH || 'C:/Users/yys53/AppData/Roaming/npm/appium.cmd', // Windows .cmd 路径
appiumJsPath: process.env.APPIUM_JS_PATH || 'C:/Users/yys53/AppData/Roaming/npm/node_modules/appium/bin/appium.js', // Appium 主 js 文件路径
// ADB 路径 (请根据你的实际安装位置修改)
adbPath: process.env.ADB_PATH || 'D:/sdk/android-studio-2024.2.2.13-windows/android-studio/platform-tools/adb.exe',
// Appium 服务器地址和端口
appiumHost: process.env.APPIUM_HOST || 'localhost',
appiumPort: parseInt(process.env.APPIUM_PORT, 10) || 4723,
// ATX Agent 包名 (用于卸载示例)
atxPackageName: 'com.github.uiautomator',
// 重试次数
maxRetryAttempts: 3,
// 应用启动超时时间 (毫秒)
appLaunchTimeout: 30000,
};
// 清理文件夹函数
async function clearFolder(folderPath) {
// ... (代码同原文) ...
try {
// 检查文件夹是否存在
if (!fs.existsSync(folderPath)) {
console.log(`文件夹不存在,无需清理: ${folderPath}`);
return;
}
const files = await fs.promises.readdir(folderPath);
await Promise.all(files.map(async (file) => {
const filePath = path.join(folderPath, file);
const stat = await fs.promises.stat(filePath);
if (stat.isDirectory()) {
// 递归删除子文件夹
await clearFolder(filePath);
await fs.promises.rmdir(filePath);
} else {
// 删除文件
await fs.promises.unlink(filePath);
}
}));
console.log(`已成功清理文件夹: ${folderPath}`);
} catch (error) {
// 忽略文件不存在的错误,但记录其他错误
if (error.code !== 'ENOENT') {
console.error(`清理文件夹失败: ${folderPath}`, error);
}
}
}
// 生成 Allure 报告函数
async function generateAllureReport(udid, appPackage) {
// ... (代码同原文, 注意路径处理和错误处理) ...
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// 构建包含 UDID 和 AppPackage 的文件夹名称,替换非法字符
const folderSuffix = `${udid.replace(/:|\./g, '_')}_${appPackage.replace(/\./g, '_')}`;
const allureResultsPath = path.resolve(__dirname, 'allure-results'); // 使用绝对路径
const allureReportPath = path.resolve(__dirname, `allure-report_${folderSuffix}`); // 使用绝对路径
try {
console.log(`📊 开始为设备 ${udid} (${appPackage}) 生成 Allure 报告...`);
// 确保 allure-results 文件夹存在
if (!fs.existsSync(allureResultsPath)) {
console.warn(`⚠️ Allure 结果文件夹不存在: ${allureResultsPath}. 可能没有测试结果.`);
// fs.mkdirSync(allureResultsPath, { recursive: true }); // 如果需要,可以创建
return; // 没有结果,直接返回
}
// 确保 allure-report 文件夹存在,如果已存在则先清理
if (fs.existsSync(allureReportPath)) {
console.log(`🧹 清理旧的 Allure 报告文件夹: ${allureReportPath}`);
await clearFolder(allureReportPath); // 清理报告目录
await fs.promises.rmdir(allureReportPath); // 删除空目录本身
}
// 不再需要创建,generate 命令会自动创建
// 使用 allure generate 命令生成报告
// 确保 allure 命令在 PATH 中,或者提供完整路径
// 双引号处理路径中的空格
const allureCmd = `allure generate "${allureResultsPath}" --clean -o "${allureReportPath}"`;
console.log('执行命令:', allureCmd);
// 短暂延时,确保文件系统操作完成
// console.log('⏳ 等待 2 秒...');
// await delay(2000);
// console.log('延时结束,开始生成报告');
await new Promise((resolve, reject) => {
// 使用 { shell: true } 以便能正确解析带引号的路径和命令
exec(allureCmd, { shell: true }, (error, stdout, stderr) => {
if (error) {
console.error(`❌ Allure 报告生成失败 (${udid}, ${appPackage}):`, error);
console.error('stderr:', stderr);
// 打印 stdout 也可能有助于调试
console.log('stdout:', stdout);
reject(new Error(`Allure generate command failed: ${stderr || error.message}`));
} else {
console.log(`✅ Allure 报告已生成: ${allureReportPath}`);
// 打印 Allure 输出,通常会提示报告生成位置
console.log('Allure generate stdout:', stdout);
resolve(stdout);
}
});
});
// **重要:** 生成报告后才清理 results 文件夹
console.log(`🧹 开始清理 Allure 结果文件夹: ${allureResultsPath}...`);
await clearFolder(allureResultsPath); // 清理 results 目录
if (fs.existsSync(allureResultsPath)) {
await fs.promises.rmdir(allureResultsPath).catch(err => {
// 如果目录非空(可能并发写入导致),记录但不中断
if (err.code !== 'ENOTEMPTY') console.error(`删除 allure-results 目录时出错: ${err}`);
});
}
} catch (error) {
console.error(`❌ Allure 报告生成或清理过程中发生错误 (${udid}, ${appPackage}):`, error);
console.error('错误堆栈:', error.stack);
// 这里可以考虑抛出错误,让调用者知道生成失败
// throw error;
}
}
// MobileApp 类
class MobileApp {
constructor(capabilities, wdOpts) {
this.capabilities = capabilities;
this.wdOpts = { ...wdOpts, capabilities }; // 合并 capabilities 到 wdOpts
this.driver = null;
this.udid = this.capabilities['appium:udid']; // 存储 udid
this.appPackage = this.capabilities['appium:appPackage']; // 存储 appPackage
console.log(`📱 初始化 MobileApp 实例: UDID=${this.udid}, App=${this.appPackage}`);
}
// 检查 Appium 服务是否在指定端口运行
async isAppiumRunning(host, port) {
// ... (代码同原文) ...
return new Promise((resolve) => {
const socket = new net.Socket();
socket.setTimeout(1000); // 设置 1 秒超时
socket.on('connect', () => {
socket.destroy();
resolve(true);
});
socket.on('error', (err) => {
// console.warn(`检查 Appium 端口 ${host}:${port} 出错: ${err.message}`);
socket.destroy();
resolve(false);
});
socket.on('timeout', () => {
// console.warn(`检查 Appium 端口 ${host}:${port} 超时`);
socket.destroy();
resolve(false);
});
socket.connect(port, host);
});
}
// 启动 Appium 服务 (注意:这种方式不够健壮,建议外部独立管理 Appium 服务)
async startAppiumServer() {
// ... (代码同原文, 添加更多日志和错误处理) ...
return new Promise((resolve, reject) => {
let appiumCmd = config.appiumCmdPath;
// 简单处理路径,更健壮的方式是使用 path.normalize 或 cross-spawn
if (process.platform === 'win32' && appiumCmd.includes('appium.cmd')) {
// Windows .cmd 文件通常可以直接执行
console.log(`尝试使用 .cmd 启动 Appium: ${appiumCmd}`);
} else if (config.appiumJsPath && fs.existsSync(config.appiumJsPath)) {
// 尝试使用 node 直接运行 appium.js
appiumCmd = `node "${config.appiumJsPath}"`;
console.log(`尝试使用 node 启动 Appium: ${appiumCmd}`);
} else {
// 尝试直接使用全局 appium 命令
appiumCmd = 'appium';
console.log(`尝试使用全局命令启动 Appium: ${appiumCmd}`);
}
console.log(`⏳ 正在执行命令启动 Appium: ${appiumCmd}`);
const appiumProcess = exec(appiumCmd, { shell: true }, (error, stdout, stderr) => {
// 这个回调在进程结束后执行,不适合判断服务是否已就绪
if (error) {
// 记录错误但不立即 reject,因为可能服务仍在后台启动中
console.error(`Appium 进程执行出错 (可能已在后台启动): ${stderr || error.message}`);
// reject(new Error(`启动 Appium 服务失败: ${stderr || error.message}`));
}
});
let resolved = false;
const resolveOnce = () => {
if (!resolved) {
resolved = true;
console.log('✅ Appium 服务似乎已启动 (检测到监听日志)');
resolve();
}
};
const rejectOnce = (err) => {
if (!resolved) {
resolved = true;
console.error('❌ 启动 Appium 服务失败');
reject(err);
}
}
// 监听标准输出查找启动成功的标志
appiumProcess.stdout.on('data', (data) => {
const output = data.toString();
console.log('Appium stdout:', output); // 打印日志方便调试
// 查找 Appium 启动成功的日志行
if (output.includes('Appium REST http interface listener started')) {
resolveOnce();
}
});
// 监听错误输出
appiumProcess.stderr.on('data', (data) => {
const errorOutput = data.toString();
console.error('Appium stderr:', errorOutput);
// 如果 stderr 包含明确的错误信息,可以提前 reject
if (errorOutput.includes('EADDRINUSE') || errorOutput.includes('Could not start REST http interface')) {
rejectOnce(new Error(`启动 Appium 服务失败: ${errorOutput}`));
}
});
// 进程退出时的处理
appiumProcess.on('close', (code) => {
if (code !== 0 && !resolved) {
rejectOnce(new Error(`Appium 进程意外退出,退出码: ${code}`));
}
});
// 添加超时机制
const startTimeout = setTimeout(() => {
rejectOnce(new Error('启动 Appium 服务超时 (30 秒)'));
}, 30000); // 30 秒超时
// 清理超时定时器
appiumProcess.on('exit', () => clearTimeout(startTimeout));
appiumProcess.on('error', (err) => { // 监听进程本身的错误,例如命令找不到
clearTimeout(startTimeout);
rejectOnce(new Error(`执行 Appium 命令失败: ${err.message}`));
});
});
}
// 连接 ADB 设备 (带重试)
async connectAdb(udid, attempts = 0) {
// ... (代码同原文) ...
if (attempts >= config.maxRetryAttempts) {
throw new Error(`❌ 连接 ADB 失败 (${udid}),超出最大重试次数 ${config.maxRetryAttempts}`);
}
return new Promise((resolve, reject) => {
const connectCmd = `"${config.adbPath}" connect ${udid}`; // 给 adb 路径加上引号
console.log(`[ADB Connect Attempt ${attempts + 1}/${config.maxRetryAttempts}] 执行: ${connectCmd}`);
exec(connectCmd, (error, stdout, stderr) => {
if (error) {
console.warn(`⚠️ ADB 连接失败 (${udid}): ${stderr || error.message}. 重试中...`);
setTimeout(() => this.connectAdb(udid, attempts + 1).then(resolve).catch(reject), 2000); // 增加重试间隔
return;
}
const output = stdout.toString();
// 检查多种可能的成功连接信息
if (output.includes('connected to') || output.includes('already connected to')) {
console.log(`✅ ADB 已连接到设备: ${udid}`);
resolve();
} else {
console.warn(`⚠️ ADB 连接响应未明确表示成功 (${udid}): ${output || '无输出'}. 重试中...`);
setTimeout(() => this.connectAdb(udid, attempts + 1).then(resolve).catch(reject), 2000);
}
});
});
}
// 初始化 Appium 会话 (带重试)
async init(attempts = 0) {
console.log(`[Init Attempt ${attempts + 1}/${config.maxRetryAttempts}] 开始初始化 Appium 会话 for ${this.udid}...`);
if (attempts >= config.maxRetryAttempts) {
throw new Error(`❌ 初始化 Appium 失败 (${this.udid}),超出最大重试次数`);
}
try {
// 1. 确保 ADB 已连接
await this.connectAdb(this.udid);
// 2. 检查 Appium 服务是否运行 (如果需要,尝试启动 - 但建议外部管理)
if (!await this.isAppiumRunning(config.appiumHost, config.appiumPort)) {
console.log(`⏳ Appium 服务在 ${config.appiumHost}:${config.appiumPort} 未运行,等待或尝试启动...`);
// await this.startAppiumServer(); // 取消注释以启用自动启动,但风险较高
// 等待一段时间,看服务是否由外部启动
await new Promise(resolve => setTimeout(resolve, 5000));
if (!await this.isAppiumRunning(config.appiumHost, config.appiumPort)) {
throw new Error(`Appium 服务在 ${config.appiumHost}:${config.appiumPort} 未启动`);
}
console.log('✅ Appium 服务已运行.');
} else {
console.log(`✅ Appium 服务已在 ${config.appiumHost}:${config.appiumPort} 运行.`);
}
// 3. 创建 WebDriver 会话
console.log(`⏳ 正在创建 WebDriver 会话 for ${this.udid}...`);
// 增加连接超时时间
this.wdOpts.connectionRetryTimeout = 120000; // 120 秒
this.wdOpts.connectionRetryCount = 3;
this.driver = await remote(this.wdOpts);
console.log(`✅ WebDriver 会话成功建立 for ${this.udid}`);
return this.driver; // 返回 driver 实例
} catch (error) {
console.warn(`⚠️ 初始化失败 (${this.udid}): ${error.message}. 重试中...`);
// 在重试前,尝试清理可能残留的会话或状态
if (this.driver) {
try {
await this.driver.deleteSession();
} catch (deleteError) {
console.warn(`清理旧会话失败: ${deleteError.message}`);
}
this.driver = null;
}
await new Promise(resolve => setTimeout(resolve, 3000 * (attempts + 1))); // 增加重试等待时间
// 递归调用 init 进行重试
return this.init(attempts + 1);
// throw error; // 如果不使用递归重试,则抛出错误
}
}
// 检查包是否安装
async isPackageInstalled(udid, packageName) {
// ... (代码同原文) ...
const listCmd = `"${config.adbPath}" -s ${udid} shell pm list packages | grep "${packageName}$"`; // 精确匹配包名结尾
return new Promise(resolve => {
exec(listCmd, (error, stdout, stderr) => {
if (error) {
// 如果 grep 没找到会返回错误码 1,这是正常的
if (error.code === 1) {
resolve(false);
} else {
console.error(`检查包安装状态出错 (${udid}, ${packageName}): ${stderr || error.message}`);
resolve(false); // 出错时保守地认为未安装或状态未知
}
return;
}
// 如果 stdout 有内容,说明包已安装
resolve(stdout?.trim() !== '');
});
});
}
// 卸载 ATX Agent (如果已安装)
async uninstallAtx(udid) {
// ... (代码同原文, 增加日志) ...
const isInstalled = await this.isPackageInstalled(udid, config.atxPackageName);
if (isInstalled) {
console.log(`⏳ 正在卸载 ATX Agent (${config.atxPackageName}) on ${udid}...`);
const uninstallCmd = `"${config.adbPath}" -s ${udid} uninstall ${config.atxPackageName}`;
return new Promise((resolve, reject) => {
exec(uninstallCmd, (error, stdout, stderr) => {
if (error) {
console.error(`❌ 卸载 ATX agent 失败 (${udid}): ${stderr || error.message}`);
reject(new Error(`卸载 ATX agent 失败: ${stderr || error.message}`));
} else {
console.log(`✅ ATX Agent 已卸载 (${udid}). Output: ${stdout}`);
resolve(stdout);
}
});
});
} else {
console.log(`ℹ️ ATX Agent (${config.atxPackageName}) 未安装在 ${udid},无需卸载.`);
return Promise.resolve('Not installed'); // 返回一个表示未安装的状态
}
}
// 查找元素 (带重试和等待)
async findElementByXPath(xpath, attempts = 0) {
// ... (代码同原文, 增加等待时间) ...
if (!this.driver) throw new Error("Driver not initialized");
const timeout = 15000; // 增加等待超时时间
console.log(`[Find Element Attempt ${attempts + 1}/${config.maxRetryAttempts}] 查找 XPath: ${xpath}`);
if (attempts >= config.maxRetryAttempts) {
throw new Error(`❌ 查找元素失败 (${this.udid}),超出最大重试次数: ${xpath}`);
}
try {
const element = await this.driver.$(xpath);
// 等待元素存在于 DOM 中
await element.waitForExist({ timeout, interval: 500 });
// 等待元素在屏幕上可见
await element.waitForDisplayed({ timeout, interval: 500 });
console.log(`✅ 找到元素: ${xpath}`);
return element;
} catch (error) {
console.warn(`⚠️ 查找元素失败: ${xpath} (${this.udid}),重试中... Error: ${error.message}`);
await new Promise(resolve => setTimeout(resolve, 1500)); // 增加重试间隔
return this.findElementByXPath(xpath, attempts + 1);
}
}
// 保存元素截图
async saveElementScreenShot(element, filePath) {
if (!this.driver) throw new Error("Driver not initialized");
try {
console.log(`📸 正在为元素截图并保存到: ${filePath}`);
await element.saveScreenshot(filePath);
console.log(`✅ 元素截图已保存: ${filePath}`);
return filePath;
} catch (error) {
console.error(`❌ 保存元素截图失败 (${this.udid}): ${error.message}`);
throw error;
}
}
// 点击元素 (带前置等待)
async clickElement(elementOrXpath) {
if (!this.driver) throw new Error("Driver not initialized");
let element = elementOrXpath;
try {
// 如果传入的是 xpath 字符串,先查找元素
if (typeof elementOrXpath === 'string') {
console.log(`🖱️ 准备点击 XPath: ${elementOrXpath}`);
element = await this.findElementByXPath(elementOrXpath);
} else {
console.log(`🖱️ 准备点击提供的元素`);
// 确保元素仍然可用和可见
await element.waitForExist({ timeout: 5000 });
await element.waitForDisplayed({ timeout: 5000 });
}
await element.click();
console.log(`✅ 元素已点击`);
// 点击后可以加一个短暂的暂停,等待 UI 响应
await this.driver.pause(500);
return true;
} catch (error) {
console.error(`❌ 点击元素失败 (${this.udid}): ${error.message}`);
// 可以在失败时尝试截图
// await this.driver.saveScreenshot(`./click_error_${Date.now()}.png`);
throw error;
}
}
// 启动应用 (使用 activateApp)
async launchApp() {
if (!this.driver) throw new Error("Driver not initialized");
try {
console.log(`🚀 正在尝试激活应用: ${this.appPackage}`);
await this.driver.activateApp(this.appPackage);
console.log(`✅ 成功发送激活应用命令: ${this.appPackage}`);
// 等待应用启动完成 (可以等待某个首页元素出现)
await this.waitForAppLaunch();
} catch (error) {
console.error(`❌ 激活/启动应用 ${this.appPackage} 失败 (${this.udid}):`, error.message);
throw error;
}
}
// 等待应用启动完成 (通过检查关键元素)
async waitForAppLaunch() {
if (!this.driver) throw new Error("Driver not initialized");
try {
// **重要:** 替换为你应用启动后必然出现的元素的 XPath 或其他定位符
const firstElementXPath = '//android.widget.FrameLayout[@resource-id="android:id/content"]//android.widget.TextView[1]'; // 示例:等待第一个 TextView 出现
// 或者更具体的元素,如首页的 Tab
// const firstElementXPath = '//android.widget.TextView[@text="首页"]';
console.log(`⏳ 等待应用 ${this.appPackage} 启动完成 (检查元素: ${firstElementXPath})...`);
await this.driver.waitUntil(async () => {
try {
const element = await this.driver.$(firstElementXPath);
const isDisplayed = await element.isDisplayed();
// console.log(`检查元素 ${firstElementXPath} 是否可见: ${isDisplayed}`);
return isDisplayed;
} catch (error) {
// 如果查找元素时出错 (例如元素还不存在),继续等待
// console.warn(`等待应用启动时查找元素出错: ${error.message}`);
return false;
}
}, {
timeout: config.appLaunchTimeout,
timeoutMsg: `❌ 应用 ${this.appPackage} 启动超时 (${config.appLaunchTimeout}ms) 未找到元素 ${firstElementXPath}`,
interval: 1000 // 每隔 1 秒检查一次
});
console.log(`✅ 应用 ${this.appPackage} 已成功启动并准备就绪.`);
} catch (error) {
console.error(`❌ 应用 ${this.appPackage} 启动失败或超时 (${this.udid}): `, error.message);
// 可以在超时时截图
// await this.driver.saveScreenshot(`./launch_error_${this.udid}_${Date.now()}.png`);
throw error;
}
}
// 关闭 WebDriver 会话
async deleteSession() {
if (this.driver) {
console.log(`⏳ 正在关闭 WebDriver 会话 for ${this.udid}...`);
try {
// 可以加一个短暂暂停,确保操作完成
// await this.driver.pause(500);
await this.driver.deleteSession();
console.log(`✅ WebDriver 会话已关闭 for ${this.udid}`);
this.driver = null; // 清理引用
} catch (error) {
console.error(`❌ 关闭 WebDriver 会话失败 for ${this.udid}: ${error.message}`);
this.driver = null; // 即使失败也清理引用
// throw error; // 可以选择是否向上抛出错误
}
} else {
console.log(`ℹ️ WebDriver 会话 for ${this.udid} 已关闭或未初始化.`);
}
}
}
module.exports = { MobileApp, config, generateAllureReport, clearFolder };
6.2 多设备测试脚本 (my.test.js
)
这个 Jest 测试脚本使用 mobileAppUtils.js
中的 MobileApp
类来初始化多个设备,并在每个设备上运行相同的测试用例。同时集成了 jest-allure
来生成报告。
// my.test.js
const { MobileApp, config, generateAllureReport, clearFolder } = require('./mobileAppUtils');
const { Severity } = require('jest-allure/dist/Reporter'); // 引入 Severity
const fs = require('fs');
const path = require('path');
// 注意:确保你已安装 jest-allure: npm install --save-dev jest-allure
// 并在 jest.config.js 中配置了 reporter: ["default", "jest-allure"]
// 或者在 package.json 的 jest 配置中添加 setupFilesAfterEnv: ["jest-allure/dist/setup"]
// 定义要测试的设备列表
const devices = [
{
udid: '127.0.0.1:5555', // 第一个设备的 UDID (模拟器或真机)
appPackage: 'com.kmxs.reader', // 第一个 App 的包名
appActivity: '.ui.LauncherUI' // 第一个 App 的启动 Activity
},
// {
// udid: 'emulator-5554', // 第二个设备的 UDID
// appPackage: 'com.example.anotherapp', // 第二个 App 的包名
// appActivity: '.MainActivity' // 第二个 App 的启动 Activity
// }
// 可以添加更多设备...
];
// 动态创建 Capabilities
function createCapabilities(device) {
return {
platformName: 'Android',
'appium:automationName': 'UiAutomator2',
'appium:deviceName': `Device_${device.udid.replace(/[:.]/g, '_')}`, // 给设备起个名
'appium:appPackage': device.appPackage,
'appium:appActivity': device.appActivity,
'appium:noReset': true,
'appium:unicodeKeyboard': true, // 推荐开启
'appium:resetKeyboard': true, // 推荐开启
'appium:udid': device.udid,
// 可以添加其他需要的 capabilities, 例如:
// 'appium:newCommandTimeout': 180, // 命令超时时间 (秒)
// 'appium:autoGrantPermissions': true, // 自动授予权限 (需要 Appium 1.9.0+)
};
}
describe('Appium 自动化测试 - 多设备/多App', () => {
let mobileApps = []; // 存储所有设备的 MobileApp 实例
// 在所有测试开始前执行 (每个设备初始化一次)
beforeAll(async () => {
console.log(`\n===== [BeforeAll] 开始初始化 ${devices.length} 个设备 =====`);
// 清理可能存在的旧 Allure 结果
const allureResultsPath = path.resolve(__dirname, 'allure-results');
await clearFolder(allureResultsPath);
if (!fs.existsSync(allureResultsPath)) {
fs.mkdirSync(allureResultsPath, { recursive: true });
}
const initPromises = devices.map(async (device) => {
const capabilities = createCapabilities(device);
const wdOpts = {
hostname: config.appiumHost,
port: config.appiumPort,
logLevel: 'warn', // 测试运行时可以降低 WebDriverIO 日志级别
// capabilities 在 MobileApp 构造函数中合并
};
const mobileApp = new MobileApp(capabilities, wdOpts);
mobileApps.push(mobileApp); // 添加到数组
try {
await mobileApp.init(); // 初始化连接
// 初始化成功后,可以选择是否立即启动 App
// await mobileApp.launchApp();
console.log(`✅ [BeforeAll] 设备 ${device.udid} 初始化成功`);
} catch (error) {
console.error(`❌ [BeforeAll] 设备 ${device.udid} 初始化失败:`, error);
// 将错误附加到报告中(如果 reporter 可用)
if (typeof reporter !== 'undefined') {
reporter.description(`设备 ${device.udid} 初始化失败`).addAttachment("初始化错误", error.stack || error.message, "text/plain");
}
// 抛出错误会阻止测试运行,根据需要决定是否抛出
throw new Error(`设备 ${device.udid} 初始化失败,测试无法继续`);
}
});
await Promise.all(initPromises); // 等待所有设备初始化完成
console.log(`===== [BeforeAll] 所有设备初始化完成 =====\n`);
}, 120000 * devices.length); // 增加超时时间,每个设备给 120 秒
// 在所有测试结束后执行
afterAll(async () => {
console.log(`\n===== [AfterAll] 开始清理会话并生成报告 =====`);
const cleanupPromises = mobileApps.map(async (mobileApp) => {
const udid = mobileApp.udid; // 从实例获取 udid
const appPackage = mobileApp.appPackage; // 从实例获取 appPackage
try {
await mobileApp.deleteSession(); // 关闭会话
} catch (deleteSessionError) {
console.error(`❌ [AfterAll] 关闭设备 ${udid} 会话失败:`, deleteSessionError);
}
});
await Promise.all(cleanupPromises); // 等待所有会话关闭
// **重要:** 确保所有测试完成且会话关闭后,再生成 Allure 报告
try {
// 只需要生成一次报告,它会包含所有测试的结果
// 假设 udid 和 appPackage 只是用于报告文件夹命名的一部分
// 可以选择第一个设备的信息,或者一个通用的名称
const firstDevice = devices[0] || { udid: 'multi', appPackage: 'device' };
await generateAllureReport(firstDevice.udid, firstDevice.appPackage);
} catch (generateReportError) {
console.error(`❌ [AfterAll] 生成 Allure 报告时出错:`, generateReportError);
}
console.log(`===== [AfterAll] 清理完成 =====\n`);
});
// === 测试用例 ===
// 每个 'it' 块都会在 beforeAll 初始化的所有设备上尝试执行
// 测试用例 1: 卸载 ATX Agent (如果存在)
it('卸载 ATX agent (如果已安装)', async () => {
// 使用 Promise.all 并行执行卸载操作
const uninstallPromises = mobileApps.map(async (mobileApp) => {
const udid = mobileApp.udid;
// 使用 jest-allure 的 reporter (确保已正确配置)
if (typeof reporter !== 'undefined') {
reporter
.description(`设备 ${udid}: 卸载 ATX agent (如果存在)`)
.story("Setup")
.severity(Severity.TRIVIAL) // 调整严重性
.testId(`Uninstall-ATX-${udid}`);
reporter.startStep(`设备 ${udid}: 开始检查并卸载 ATX agent`);
} else {
console.warn("jest-allure reporter 未定义,无法记录步骤和详情。请检查 Jest 配置。");
}
try {
await mobileApp.uninstallAtx(udid);
if (typeof reporter !== 'undefined') reporter.endStep(); // 标记步骤成功
// Jest 断言:这里我们期望卸载操作不抛出错误
expect(true).toBe(true);
} catch (error) {
console.error(`❌ 设备 ${udid}: 卸载 ATX agent 时出错:`, error);
if (typeof reporter !== 'undefined') {
reporter.addAttachment("卸载错误日志", error.stack || error.message, "text/plain");
reporter.endStep('failed'); // 标记步骤失败
}
// 让测试失败
// expect(false).toBeTruthy(`设备 ${udid}: 卸载 ATX agent 失败: ${error.message}`);
throw error; // 抛出错误使测试失败
}
});
await Promise.all(uninstallPromises); // 等待所有设备的卸载操作完成
});
// 测试用例 2: 点击 "福利" 按钮
it('点击 "福利" 按钮', async () => {
const clickPromises = mobileApps.map(async (mobileApp) => {
const udid = mobileApp.udid;
if (typeof reporter !== 'undefined') {
reporter
.description(`设备 ${udid}: 点击 "福利" 按钮`)
.feature("主页功能")
.story("浏览福利")
.severity(Severity.NORMAL)
.testId(`Click-Welfare-${udid}`);
reporter.startStep(`设备 ${udid}: 开始查找并点击 "福利" 按钮`);
}
try {
// 使用封装的方法查找并点击
// 注意 XPath 可能需要根据实际 App 调整
const welfareButtonXPath = '//android.widget.TextView[@text="福利"]';
await mobileApp.clickElement(welfareButtonXPath);
if (typeof reporter !== 'undefined') reporter.endStep();
expect(true).toBe(true); // 操作成功
} catch (error) {
console.error(`❌ 设备 ${udid}: 点击 "福利" 按钮时出错:`, error);
if (typeof reporter !== 'undefined') {
// 失败时截图并附加到报告
try {
const screenshotPath = path.resolve(__dirname, `allure-results/error_click_welfare_${udid}_${Date.now()}.png`);
await mobileApp.driver.saveScreenshot(screenshotPath);
const screenshotBuffer = fs.readFileSync(screenshotPath);
reporter.addAttachment(`错误截图_${udid}`, screenshotBuffer, "image/png");
} catch (screenshotError) {
console.error(`截图失败: ${screenshotError.message}`);
}
reporter.addAttachment("点击错误日志", error.stack || error.message, "text/plain");
reporter.endStep('failed');
}
// expect(false).toBeTruthy(`设备 ${udid}: 点击 "福利" 按钮失败: ${error.message}`);
throw error;
}
});
await Promise.all(clickPromises);
});
// 测试用例 3: 对 "我的" 元素截图
it('对 "我的" 元素截图', async () => {
const screenshotPromises = mobileApps.map(async (mobileApp) => {
const udid = mobileApp.udid;
if (typeof reporter !== 'undefined') {
reporter
.description(`设备 ${udid}: 对 "我的" Tab 进行截图`)
.feature("个人中心")
.severity(Severity.MINOR)
.testId(`Screenshot-Mine-${udid}`);
reporter.startStep(`设备 ${udid}: 开始查找 "我的" 元素并截图`);
}
try {
// 查找 "我的" Tab 元素
const mineTabXPath = '//android.widget.TextView[@text="我的"]';
const mineTabElement = await mobileApp.findElementByXPath(mineTabXPath);
// 定义截图保存路径 (保存到 allure-results 以便自动附加)
const screenshotFileName = `mine_tab_${udid}_${Date.now()}.png`;
const screenshotPath = path.resolve(__dirname, 'allure-results', screenshotFileName);
// 使用封装的方法截图
await mobileApp.saveElementScreenShot(mineTabElement, screenshotPath);
// 读取截图文件为 Buffer
const screenshotBuffer = await fs.promises.readFile(screenshotPath);
if (screenshotBuffer.length > 0 && typeof reporter !== 'undefined') {
// 将截图作为附件添加到 Allure 报告
reporter.addAttachment(`"我的" Tab 截图 - ${udid}`, screenshotBuffer, "image/png");
reporter.endStep();
} else if (screenshotBuffer.length === 0) {
console.error(`⚠️ 设备 ${udid}: 保存的截图文件为空 ${screenshotPath}`);
if (typeof reporter !== 'undefined') reporter.endStep('broken'); // 标记为损坏
} else {
console.warn("无法添加截图到 Allure 报告 (reporter 未定义或截图为空)");
if (typeof reporter !== 'undefined') reporter.endStep();
}
expect(screenshotBuffer.length).toBeGreaterThan(0); // 断言截图文件不为空
} catch (error) {
console.error(`❌ 设备 ${udid}: 对 "我的" 元素截图时出错:`, error);
if (typeof reporter !== 'undefined') {
try {
const screenshotPath = path.resolve(__dirname, `allure-results/error_screenshot_mine_${udid}_${Date.now()}.png`);
await mobileApp.driver.saveScreenshot(screenshotPath);
const screenshotBuffer = fs.readFileSync(screenshotPath);
reporter.addAttachment(`错误截图_${udid}`, screenshotBuffer, "image/png");
} catch (screenshotError) {
console.error(`截图失败: ${screenshotError.message}`);
}
reporter.addAttachment("截图错误日志", error.stack || error.message, "text/plain");
reporter.endStep('failed');
}
// expect(false).toBeTruthy(`设备 ${udid}: 截图失败: ${error.message}`);
throw error;
}
});
await Promise.all(screenshotPromises);
});
});
6.3 集成 Allure 报告 Integrating Allure Reports
- 安装:
npm install --save-dev jest-allure allure-commandline
- 配置 Jest: 在
jest.config.js
或package.json
的jest
部分配置 reporter。// jest.config.js module.exports = { // ... 其他配置 reporters: ['default', 'jest-allure'], setupFilesAfterEnv: ['jest-allure/dist/setup'], // ... };
- 生成报告:
- 运行 Jest 测试 (
npm test
),测试结果 (XML 文件) 会默认生成在./allure-results
目录下。 - 使用
allure
命令行工具生成 HTML 报告:npx allure generate ./allure-results --clean -o ./allure-report
- 打开报告:
npx allure open ./allure-report
- 运行 Jest 测试 (
- 在测试代码中使用
reporter
: 如my.test.js
示例所示,可以通过全局的reporter
对象添加描述、步骤、附件、严重性等信息。 - 注意:
generateAllureReport
函数应在所有测试执行完毕 之后 (afterAll
钩子中),并且在清理allure-results
目录 之前 调用。清理allure-results
应在报告生成 之后 进行。
7. 实现多线程/并行测试 ⚡ Implementing Multi-threading/Parallel Testing
直接在单个 Jest 进程中使用 ESM 运行多个 WebDriverIO 实例(尤其是在多设备上)可能会遇到资源竞争、端口冲突或 ESM 加载的复杂性问题。一种解决方法是将测试任务分发到不同的 Node.js 进程中执行。
7.1 背景:ESM 与 Jest 多线程限制 Background: ESM & Jest Multi-threading Limitations
- Jest 的并行执行 (
--maxWorkers
> 1) 在启用--experimental-vm-modules
时可能行为不稳定或导致上述错误。 - 每个 WebDriverIO 实例都需要连接到一个独立的 Appium 会话,并行运行时需要确保 Appium 服务器能够处理并发请求,或者为每个进程启动独立的 Appium 服务 (可能需要不同端口)。
7.2 解决方案:脚本拆分与 process.argv
Solution: Script Splitting & process.argv
这种方法通过一个主控脚本 (runTests.js
) 来启动多个独立的 Jest 进程,每个进程负责一个或一部分测试任务(例如,一个设备)。通过命令行参数 (process.argv
) 将设备信息等传递给子进程。
7.3 启动脚本 (runTests.js
)
这个脚本接收测试文件名前缀和设备 IP 列表作为参数,为每个设备启动一个单独的 Jest 进程来运行匹配的测试文件。
// runTests.js
const { spawn } = require('cross-spawn'); // 使用 cross-spawn 保证跨平台兼容性
const path = require('path');
const fs = require('fs');
const { clearFolder } = require('./mobileAppUtils'); // 引入清理函数
// 获取命令行参数: node runTests.js <filePrefix> [deviceIP1] [deviceIP2] ...
const args = process.argv.slice(2);
const filePrefix = args[0]; // 测试文件名前缀
const deviceIPs = args.slice(1); // 设备 IP 列表
// 查找匹配前缀的测试文件
async function findTestFiles(prefix) {
if (!prefix) return []; // 如果没有提供前缀,返回空数组
const files = await fs.promises.readdir(__dirname); // 在当前目录下查找
// 过滤出以 prefix 开头并以 .test.js 结尾的文件
const testFiles = files.filter(file => file.startsWith(prefix) && file.endsWith('.test.js'));
return testFiles.map(file => path.resolve(__dirname, file)); // 返回文件的绝对路径
}
// 为单个设备运行测试文件
async function runTest(testFilePath, deviceIP) {
return new Promise((resolve, reject) => {
console.log(`\n🚀 [RunTest] 开始为设备 ${deviceIP} 执行测试文件: ${path.basename(testFilePath)}`);
// 准备传递给 Jest 进程的环境变量和参数
const jestArgs = [
'--experimental-vm-modules', // 开启 ESM 支持
path.resolve(__dirname, 'node_modules', 'jest-cli', 'bin', 'jest.js'), // Jest CLI 路径
testFilePath, // 要运行的测试文件绝对路径
'--runInBand', // 在子进程内串行执行测试(如果一个文件包含多个 describe/it)
// 通过 Jest 的 globals 或 testEnvironmentOptions 传递设备 IP 比命令行参数更优雅
// 但这里我们遵循原文,使用自定义参数,需要在测试文件中解析 process.env.DEVICE_IP
// 或者使用 jest --testEnvironmentOptions='{"deviceIP":"' + deviceIP + '"}'
];
// 设置环境变量传递设备 IP
const env = {
...process.env,
NODE_OPTIONS: '--experimental-vm-modules', // 确保子进程也有此选项
DEVICE_IP: deviceIP // 将设备 IP 设置为环境变量
};
// 使用 spawn 启动 Jest 进程
const jestProcess = spawn(
'node', // 运行 node
jestArgs,
{
env: env, // 传递环境变量
stdio: 'inherit', // 将子进程的 stdio 连接到父进程 (实时看到输出)
shell: false // 通常不需要 shell: true
}
);
// 监听进程关闭事件
jestProcess.on('close', (code) => {
if (code === 0) {
console.log(`✅ [RunTest] 设备 ${deviceIP} 测试完成: ${path.basename(testFilePath)}`);
resolve(); // 成功
} else {
console.error(`❌ [RunTest] 设备 ${deviceIP} 测试失败,退出码: ${code}. 文件: ${path.basename(testFilePath)}`);
reject(new Error(`测试失败,设备: ${deviceIP}, 文件: ${path.basename(testFilePath)}`)); // 失败
}
});
// 监听进程错误事件 (例如命令找不到)
jestProcess.on('error', (err) => {
console.error(`❌ [RunTest] 启动 Jest 进程失败 (设备 ${deviceIP}): ${err.message}`);
reject(err);
});
});
}
// 主执行函数
async function main() {
console.log("===== [Main] 开始执行测试任务 =====");
if (!filePrefix) {
console.error("错误: 请提供测试文件名前缀作为第一个参数。");
console.log("用法: node runTests.js <filePrefix> [deviceIP1] [deviceIP2] ...");
process.exit(1);
}
// 先清理 allure-results 文件夹
const allureResultsPath = path.resolve(__dirname, 'allure-results');
console.log(`🧹 [Main] 清理 Allure 结果文件夹: ${allureResultsPath}`);
await clearFolder(allureResultsPath);
// 清理后确保目录存在,因为 jest-allure 需要写入
if (!fs.existsSync(allureResultsPath)) {
fs.mkdirSync(allureResultsPath, { recursive: true });
}
try {
const testFiles = await findTestFiles(filePrefix);
if (testFiles.length === 0) {
console.error(`❌ [Main] 没有找到匹配前缀 "${filePrefix}" 的测试文件 (*.test.js)`);
process.exit(1);
}
// 目前只取找到的第一个测试文件来运行,可以扩展为运行所有找到的文件
const testFileToRun = testFiles[0];
console.log(`ℹ️ [Main] 将在指定设备上运行测试文件: ${path.basename(testFileToRun)}`);
let targetDevices = deviceIPs;
if (targetDevices.length === 0) {
console.warn("⚠️ [Main] 未提供设备 IP,将使用默认设备: 127.0.0.1:5555");
targetDevices = ['127.0.0.1:5555']; // 默认设备
}
// 创建所有测试运行的 Promise
const testPromises = targetDevices.map(device => runTest(testFileToRun, device));
// 等待所有测试进程完成
// 使用 Promise.allSettled 可以知道每个任务的成功或失败状态
const results = await Promise.allSettled(testPromises);
// 检查是否有失败的测试
const failedTests = results.filter(result => result.status === 'rejected');
if (failedTests.length > 0) {
console.error(`\n❌ [Main] ${failedTests.length} 个设备的测试执行失败:`);
failedTests.forEach(result => console.error(` - ${result.reason.message || result.reason}`));
console.log("===== [Main] 部分测试执行失败 =====");
process.exit(1); // 以失败状态退出
} else {
console.log("\n✅ [Main] 所有设备的测试均已成功完成。");
console.log("===== [Main] 测试任务完成 =====");
// 可以在这里自动生成 Allure 报告
// try {
// console.log("\n📊 [Main] 开始生成 Allure 报告...");
// const reportCmd = `npx allure generate ${allureResultsPath} --clean -o ./allure-report`;
// await new Promise((resolve, reject) => {
// exec(reportCmd, { shell: true }, (err, stdout, stderr) => {
// if (err) {
// console.error("生成 Allure 报告失败:", stderr || err);
// reject(err);
// } else {
// console.log("Allure 报告已生成:", stdout);
// resolve();
// }
// });
// });
// } catch (reportError) {
// console.error("生成 Allure 报告时出错:", reportError);
// }
}
} catch (error) {
console.error('\n❌ [Main] 执行过程中发生意外错误:', error);
process.exit(1);
}
}
// 运行主函数
main();
// 注意:在 runTests.js 结束时清理 allure-results 可能不是最佳时机,
// 因为 Allure 报告生成可能还没完成(如果选择在 main 函数末尾生成)。
// 建议在下次运行 runTests.js 之前,或者在 CI/CD 流程的单独步骤中进行清理。
// clearFolder(path.resolve(__dirname, 'allure-results')).catch(console.error);
如何在测试脚本中获取设备 IP?
在 my.test.js
(或由 runTests.js
调用的其他测试文件) 中,可以通过 process.env.DEVICE_IP
来获取由 runTests.js
设置的环境变量:
// 在 my.test.js 或类似的测试文件顶部
const currentDeviceIP = process.env.DEVICE_IP || 'default-device-ip'; // 获取设备 IP
// 在创建 capabilities 时使用
function createCapabilities() {
return {
// ...
'appium:udid': currentDeviceIP, // 使用环境变量中的 IP/UDID
// ...
};
}
// 在测试描述或报告中使用
describe(`Appium 测试 - 设备: ${currentDeviceIP}`, () => {
// ...
it(`设备 ${currentDeviceIP}: 执行某个操作`, async () => {
// ...
});
});
7.4 package.json
示例 Example package.json
{
"name": "appium-jest-webdriverio-project",
"version": "1.0.0",
"description": "Appium automation tests with Jest and WebDriverIO",
"main": "index.js",
"scripts": {
"start-tests": "node runTests.js",
"test": "npm run start-tests",
"test:my": "node runTests.js my 127.0.0.1:5555", // 示例:运行 my*.test.js 在指定设备
"test:all-devices": "node runTests.js my 127.0.0.1:5555 192.168.1.101:5555", // 示例:在多个设备上运行
"report:generate": "allure generate ./allure-results --clean -o ./allure-report",
"report:open": "allure open ./allure-report",
"debug:single": "cross-env NODE_OPTIONS='--experimental-vm-modules' DEVICE_IP='127.0.0.1:5555' jest my.test.js --runInBand" // 单独调试一个文件
},
"devDependencies": {
"@babel/core": "^7.24.0", // 版本可能需要根据你的项目调整
"@babel/preset-env": "^7.24.0",
"@babel/register": "^7.23.7", // 如果使用 Babel 动态编译
"@wdio/allure-reporter": "^8.35.1", // WebDriverIO 的 Allure 报告器 (如果直接用 WDIO Testrunner)
"allure-commandline": "^2.27.0", // Allure 命令行工具
"babel-jest": "^29.7.0", // Babel 集成 Jest
"cross-env": "^7.0.3", // 跨平台设置环境变量
"cross-spawn": "^7.0.3", // 跨平台执行命令
"jest": "^29.7.0", // Jest 测试框架
"jest-allure": "^0.1.3", // Jest 的 Allure 报告集成
"jest-cli": "^29.7.0", // Jest 命令行接口
"rimraf": "^5.0.5" // 用于清理文件夹 (可选)
},
"dependencies": {
"@xmldom/xmldom": "^0.8.10", // XML 解析 (如果需要 getPageSource)
"webdriverio": "^8.35.1", // WebDriverIO 客户端
"xpath": "^0.0.34" // XPath 解析 (如果需要)
// 其他项目依赖...
},
"jest": {
"reporters": [
"default",
"jest-allure"
],
"setupFilesAfterEnv": [
"jest-allure/dist/setup"
],
"testEnvironment": "node" // 明确测试环境
// 如果使用 Babel:
// "transform": {
// "^.+\\.js$": "babel-jest"
// }
}
}
本文作者: 永生
本文链接: https://yys.zone/detail/?id=298
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!
发表评论
评论列表 (0 条评论)
暂无评论,快来抢沙发吧!