移动端自动化测试实战——Appium + Java 的 Android/iOS 测试方案
2026/4/30大约 6 分钟
移动端自动化测试实战——Appium + Java 的 Android/iOS 测试方案
适读人群:需要做移动端自动化测试的工程师 | 阅读时长:约 18 分钟 | 核心价值:从零搭建 Appium + Java 的移动端测试体系,覆盖 Android 和 iOS 双平台
那个在凌晨发现的 iOS 崩溃
我们有个 App,做了一个新版本的个人中心改版。测试工程师小胡在 Android 上测了好多遍,没问题。
上线后第二天,iOS 用户开始投诉:个人中心一进去就闪退。
紧急回滚,查日志,发现是 iOS 上某个 API 返回了不同的字段结构,UI 代码没有做空判断,导致 NullPointerException。
Android 上没问题是因为 Android 端的 API 版本恰好多了一个字段;iOS 端用了不同的 API 版本,缺少这个字段。
如果有 Appium 的 iOS 自动化测试覆盖这个场景,就能在发布前发现问题。
Appium 架构简介
Appium 是一个开源的移动端自动化测试框架,使用 WebDriver 协议,支持 Android 和 iOS。
测试代码 (Java)
↓ WebDriver 协议
Appium Server
↓
Android: UIAutomator2 Driver
iOS: XCUITest Driver
↓
真机 / 模拟器核心概念:
- Appium Server:接收 WebDriver 请求,转发给设备驱动
- UIAutomator2:Android 原生测试框架(API 21+)
- XCUITest:iOS 原生测试框架
- Capabilities:告诉 Appium 怎么连接到设备的配置
环境搭建
安装 Appium
# 安装 Node.js(Appium 依赖 Node.js)
# 下载地址:https://nodejs.org/
# 全局安装 Appium
npm install -g appium
# 安装 Android 驱动
appium driver install uiautomator2
# 安装 iOS 驱动
appium driver install xcuitest
# 验证安装
appium driver list --installedAndroid 环境配置
# 需要安装 Android Studio 和 SDK
# 配置环境变量(macOS ~/.zshrc)
export ANDROID_HOME=$HOME/Library/Android/sdk
export PATH=$PATH:$ANDROID_HOME/platform-tools
export PATH=$PATH:$ANDROID_HOME/emulator
# 验证 ADB 连接
adb devices
# 输出:
# List of devices attached
# emulator-5554 deviceiOS 环境配置(仅 macOS)
# 安装 Xcode(App Store)
# 安装 Xcode Command Line Tools
xcode-select --install
# 安装 ios-deploy(真机测试需要)
npm install -g ios-deploy
# 验证模拟器
xcrun simctl list devices | grep "iPhone"Maven 依赖
<dependencies>
<!-- Appium Java Client -->
<dependency>
<groupId>io.appium</groupId>
<artifactId>java-client</artifactId>
<version>8.6.0</version>
</dependency>
<!-- JUnit 5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
<!-- Selenium(Appium 依赖) -->
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.16.1</version>
</dependency>
</dependencies>Android 测试
基础配置
import io.appium.java_client.android.AndroidDriver;
import io.appium.java_client.android.options.UiAutomator2Options;
import org.junit.jupiter.api.*;
import java.net.URL;
import java.time.Duration;
public class AndroidBaseTest {
protected static AndroidDriver driver;
@BeforeAll
static void setup() throws Exception {
UiAutomator2Options options = new UiAutomator2Options()
.setDeviceName("emulator-5554") // 设备名称或 UDID
.setApp("/path/to/your-app.apk") // APK 路径(或 package+activity)
.setAppPackage("com.yourcompany.app") // App 包名
.setAppActivity(".MainActivity") // 启动 Activity
.setNoReset(false) // 每次测试重置 App 状态
.setAutoGrantPermissions(true) // 自动授予权限
.setNewCommandTimeout(Duration.ofSeconds(300));
driver = new AndroidDriver(
new URL("http://localhost:4723"), // Appium Server 地址
options
);
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));
}
@AfterAll
static void tearDown() {
if (driver != null) {
driver.quit();
}
}
}Android UI 元素定位
import io.appium.java_client.AppiumBy;
import org.openqa.selenium.By;
public class LoginScreenTest extends AndroidBaseTest {
@Test
void shouldLoginSuccessfully() {
// 方式一:通过 resource-id(推荐,最稳定)
driver.findElement(By.id("com.yourapp:id/email_input"))
.sendKeys("test@example.com");
driver.findElement(By.id("com.yourapp:id/password_input"))
.sendKeys("Password123!");
driver.findElement(By.id("com.yourapp:id/login_button")).click();
// 等待首页加载
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15));
WebElement welcomeText = wait.until(
ExpectedConditions.visibilityOfElementLocated(
By.id("com.yourapp:id/welcome_text")
)
);
assertTrue(welcomeText.getText().contains("欢迎"));
}
@Test
void shouldShowErrorForInvalidCredentials() {
// 通过 accessibility id(contentDescription)
driver.findElement(AppiumBy.accessibilityId("email_field"))
.sendKeys("wrong@test.com");
driver.findElement(AppiumBy.accessibilityId("password_field"))
.sendKeys("wrongpassword");
driver.findElement(AppiumBy.accessibilityId("login_button")).click();
// 方式三:通过 XPath(当其他方式失效时)
WebElement errorMsg = new WebDriverWait(driver, Duration.ofSeconds(5))
.until(ExpectedConditions.visibilityOfElementLocated(
By.xpath("//android.widget.TextView[@resource-id='com.yourapp:id/error_message']")
));
assertEquals("用户名或密码错误", errorMsg.getText());
}
}iOS 测试
基础配置
import io.appium.java_client.ios.IOSDriver;
import io.appium.java_client.ios.options.XCUITestOptions;
public class iOSBaseTest {
protected static IOSDriver driver;
@BeforeAll
static void setup() throws Exception {
XCUITestOptions options = new XCUITestOptions()
.setDeviceName("iPhone 15 Pro") // 模拟器名称
.setPlatformVersion("17.2") // iOS 版本
.setApp("/path/to/your-app.app") // .app 文件路径(模拟器)
// 真机需要:
// .setUdid("device-udid")
// .setApp("path/to/app.ipa")
// .setXcodeOrgId("your-team-id")
// .setXcodeSigningId("iPhone Developer")
.setAutoAcceptAlerts(true) // 自动接受系统弹窗
.setNoReset(false)
.setNewCommandTimeout(Duration.ofSeconds(300));
driver = new IOSDriver(
new URL("http://localhost:4723"),
options
);
}
@AfterAll
static void tearDown() {
if (driver != null) {
driver.quit();
}
}
}iOS UI 元素定位
public class iOSLoginTest extends iOSBaseTest {
@Test
void shouldLoginOnIOS() {
// iOS 推荐使用 accessibility id(对应 accessibilityIdentifier)
driver.findElement(AppiumBy.accessibilityId("emailTextField"))
.sendKeys("test@example.com");
driver.findElement(AppiumBy.accessibilityId("passwordTextField"))
.sendKeys("Password123!");
driver.findElement(AppiumBy.accessibilityId("loginButton")).click();
// iOS 上等待元素
new WebDriverWait(driver, Duration.ofSeconds(15))
.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.accessibilityId("welcomeLabel")
));
}
@Test
void shouldHandleLocationPermission() {
// 在 setup 中设置了 autoAcceptAlerts=true,权限弹窗会自动接受
// 如果需要手动处理:
try {
driver.switchTo().alert().accept();
} catch (Exception e) {
// 没有弹窗,忽略
}
}
}跨平台 Page Object 设计
// 抽象的 LoginPage,平台无关
public abstract class MobileLoginPage {
protected AppiumDriver driver;
public MobileLoginPage(AppiumDriver driver) {
this.driver = driver;
}
public abstract void enterEmail(String email);
public abstract void enterPassword(String password);
public abstract void tapLoginButton();
// 公共的业务方法
public MobileHomePage loginAs(String email, String password) {
enterEmail(email);
enterPassword(password);
tapLoginButton();
return createHomePage();
}
protected abstract MobileHomePage createHomePage();
}
// Android 实现
public class AndroidLoginPage extends MobileLoginPage {
public AndroidLoginPage(AndroidDriver driver) {
super(driver);
}
@Override
public void enterEmail(String email) {
driver.findElement(By.id("com.yourapp:id/email_input")).sendKeys(email);
}
@Override
public void enterPassword(String password) {
driver.findElement(By.id("com.yourapp:id/password_input")).sendKeys(password);
}
@Override
public void tapLoginButton() {
driver.findElement(By.id("com.yourapp:id/login_button")).click();
}
@Override
protected MobileHomePage createHomePage() {
return new AndroidHomePage((AndroidDriver) driver);
}
}
// iOS 实现
public class iOSLoginPage extends MobileLoginPage {
public iOSLoginPage(IOSDriver driver) {
super(driver);
}
@Override
public void enterEmail(String email) {
driver.findElement(AppiumBy.accessibilityId("emailTextField")).sendKeys(email);
}
@Override
public void enterPassword(String password) {
driver.findElement(AppiumBy.accessibilityId("passwordTextField")).sendKeys(password);
}
@Override
public void tapLoginButton() {
driver.findElement(AppiumBy.accessibilityId("loginButton")).click();
}
@Override
protected MobileHomePage createHomePage() {
return new iOSHomePage((IOSDriver) driver);
}
}踩坑实录
坑一:元素点击后没有响应(手势问题)
现象: click() 不触发,但元素可见且 enabled。
原因: 移动端某些控件对 tap 和 click 有区分,或者元素坐标计算有误。
解法:
// 方案一:使用 TouchAction(Appium 特有)
new TouchAction<>(driver)
.tap(TapOptions.tapOptions().withElement(element(loginButton)))
.perform();
// 方案二:通过坐标点击
Point location = driver.findElement(AppiumBy.accessibilityId("loginButton")).getLocation();
Dimension size = driver.findElement(AppiumBy.accessibilityId("loginButton")).getSize();
int centerX = location.getX() + size.getWidth() / 2;
int centerY = location.getY() + size.getHeight() / 2;
new TouchAction<>(driver).tap(PointOption.point(centerX, centerY)).perform();坑二:iOS 键盘遮挡输入框
现象: 点击输入框弹出键盘后,键盘遮挡了下方的登录按钮,无法点击。
解法:
// 方案一:先收起键盘
driver.hideKeyboard();
loginButton.click();
// 方案二:滚动到元素可见区域
((JavascriptExecutor) driver).executeScript(
"mobile: scroll",
Map.of("direction", "down", "predicateString", "name == 'loginButton'")
);
loginButton.click();坑三:模拟器和真机的定位差异
现象: 模拟器上通过 resource-id 定位的元素,在真机上找不到。
原因: 某些混淆后的 release build 会修改 resource-id。
解法:
- 测试用 debug build,关闭代码混淆
- 或者在 App 代码里为测试元素添加
accessibility identifier,比 resource-id 更稳定
// iOS 代码
loginButton.accessibilityIdentifier = "loginButton" // 定义 accessibilityId// Android 代码
loginButton.contentDescription = "loginButton" // 定义 contentDescriptionCI 集成:云设备农场
本地 Appium 测试速度慢,设备覆盖有限。生产级的移动测试通常接入云设备平台:
- BrowserStack App Automate:支持 3000+ 真实设备
- Sauce Labs:Android/iOS 双端支持
- AWS Device Farm:AWS 生态
以 BrowserStack 为例:
UiAutomator2Options options = new UiAutomator2Options()
.setDeviceName("Samsung Galaxy S23")
.setPlatformVersion("13.0")
.setApp("bs://your-app-id"); // BrowserStack 上传的 App ID
// 添加 BrowserStack 凭证
HashMap<String, Object> bsOptions = new HashMap<>();
bsOptions.put("userName", System.getenv("BROWSERSTACK_USERNAME"));
bsOptions.put("accessKey", System.getenv("BROWSERSTACK_ACCESS_KEY"));
options.setCapability("bstack:options", bsOptions);
driver = new AndroidDriver(
new URL("https://hub.browserstack.com/wd/hub"),
options
);小结
Appium + Java 移动端测试的关键:
- 优先使用 accessibilityId 定位:稳定,跨平台,前提是 App 代码要配合设置
- Page Object 模式 + 平台抽象:隔离 Android/iOS 差异,共享业务逻辑
- 真机 > 模拟器:关键功能测试用真机,回归测试可以用模拟器
- 云设备平台:不可能在本地维护几十台不同机型,云平台是生产级方案
