Appium+Java自动化测试实战:从零搭建计算器APP测试框架(附完整源码)
从零构建企业级Appium+Java自动化测试框架:以计算器APP为蓝本的深度实战
如果你已经对Java编程有了一定的了解,正摩拳擦掌想要进入移动端自动化测试领域,却苦于不知如何将零散的知识点串联成一个可运行、可维护的实战项目,那么这篇文章正是为你准备的。我们将不再满足于简单的“点击-断言”脚本,而是以一款常见的计算器APP为测试对象,手把手带你搭建一个结构清晰、扩展性强、符合企业级实践标准的自动化测试框架。这个过程,不仅仅是学习工具的使用,更是对测试工程化思维的深度训练。
1. 环境配置与项目骨架搭建:奠定坚实基础
在开始编写任何一行测试代码之前,一个稳定、可复现的测试环境是成功的基石。很多初学者在这里就栽了跟头,不是因为技术有多难,而是因为环境变量冲突、版本不匹配等“琐事”。我们将采用当前最主流的工具链,并引入容器化技术来规避环境问题。
首先,确保你的开发机已安装以下核心组件:
- JDK 17或更高版本:LTS版本能提供更好的长期支持。
- Maven 3.8+:用于依赖管理和项目构建。
- IntelliJ IDEA(社区版或旗舰版):强大的Java IDE,能极大提升开发效率。
- Docker Desktop:我们将使用Appium的官方Docker镜像来运行服务器,实现环境隔离。
接下来,我们通过Maven快速初始化项目结构。打开终端,执行以下命令:
mvn archetype:generate -DgroupId=com.yourcompany.automation -DartifactId=calculator-test-framework -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false
这会在当前目录下生成一个标准的Maven项目。进入项目目录,打开pom.xml文件,我们需要添加关键的依赖。除了基础的Appium Java Client和TestNG,一个健壮的测试框架还需要日志、报告和工具库的支持。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.yourcompany.automation</groupId>
<artifactId>calculator-test-framework</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<appium.version>9.4.0</appium.version>
<testng.version>7.8.0</testng.version>
<selenium.version>4.15.0</selenium.version>
<logback.version>1.4.14</logback.version>
<extentreports.version>5.1.1</extentreports.version>
</properties>
<dependencies>
<!-- Appium Java Client -->
<dependency>
<groupId>io.appium</groupId>
<artifactId>java-client</artifactId>
<version>${appium.version}</version>
</dependency>
<!-- TestNG 测试框架 -->
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>${testng.version}</version>
<scope>test</scope>
</dependency>
<!-- Selenium (Appium底层依赖) -->
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>${selenium.version}</version>
</dependency>
<!-- 日志框架 -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>${logback.version}</version>
</dependency>
<!-- 用于生成美观的HTML测试报告 -->
<dependency>
<groupId>com.aventstack</groupId>
<artifactId>extentreports</artifactId>
<version>${extentreports.version}</version>
</dependency>
<!-- 简化属性文件读取 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-configuration2</artifactId>
<version>2.9.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<!-- 指定TestNG的XML套件文件 -->
<suiteXmlFiles>
<suiteXmlFile>testng.xml</suiteXmlFile>
</suiteXmlFiles>
</configuration>
</plugin>
</plugins>
</build>
</project>
提示:使用Docker运行Appium Server能完美解决“在我机器上能跑”的经典问题。通过一行命令
docker run --rm -p 4723:4723 --privileged -v /dev/bus/usb:/dev/bus/usb appium/appium:latest即可启动一个干净的Appium服务环境,特别适合在CI/CD流水线中集成。
环境就绪后,我们开始设计项目的目录结构。一个良好的结构是代码可维护性的前提。我建议采用以下分层方式:
src/test/java/com/yourcompany/automation/
├── base/ # 基础层
│ ├── BaseTest.java # 所有测试类的父类,封装setup/teardown
│ └── DriverManager.java # 驱动管理,单例模式管理AppiumDriver
├── pages/ # 页面对象层 (Page Object Model)
│ ├── CalculatorPage.java
│ └── elements/ # 页面元素定位器常量
│ └── CalculatorLocators.java
├── tests/ # 测试用例层
│ ├── CalculatorBasicOperationsTest.java
│ └── CalculatorEdgeCasesTest.java
├── utils/ # 工具类层
│ ├── ConfigReader.java # 读取配置文件
│ ├── WaitHelper.java # 显式等待封装
│ └── ScreenshotUtil.java # 截图工具
├── listeners/ # 监听器层
│ └── TestListener.java # 用于生成报告和日志
└── resources/ # 资源文件
├── config.properties # 配置文件
└── testng.xml # TestNG测试套件配置
这种结构清晰地将不同职责的代码分离,符合单一职责原则。当测试用例数量增长到数百个时,你依然能轻松地找到并修改任何一部分。
2. 核心框架设计:Driver管理与页面对象模型
有了项目骨架,接下来我们要构建框架的核心——稳定的驱动管理和可复用的页面对象。很多教程直接在一个测试类里初始化AndroidDriver,这在小型demo中没问题,但在实际项目中会导致代码重复和资源泄露。
我们先从DriverManager开始。这个类采用单例模式,确保在整个测试运行期间,只有一个AppiumDriver实例被创建和销毁,这对于并行测试和资源管理至关重要。
package com.yourcompany.automation.base;
import io.appium.java_client.AppiumDriver;
import io.appium.java_client.android.AndroidDriver;
import io.appium.java_client.android.options.UiAutomator2Options;
import org.openqa.selenium.remote.DesiredCapabilities;
import java.net.MalformedURLException;
import java.net.URL;
import java.time.Duration;
public class DriverManager {
private static ThreadLocal<AppiumDriver> driver = new ThreadLocal<>();
private static final String APPIUM_SERVER_URL = "http://localhost:4723";
private DriverManager() {} // 私有构造,防止外部实例化
public static AppiumDriver getDriver() {
if (driver.get() == null) {
initializeDriver();
}
return driver.get();
}
private static synchronized void initializeDriver() {
if (driver.get() != null) {
return;
}
UiAutomator2Options options = new UiAutomator2Options();
// 从配置文件读取,这里为演示写死,实际项目应使用ConfigReader
options.setPlatformName("Android");
options.setPlatformVersion("13.0");
options.setDeviceName("Pixel_6_Pro_API_33");
options.setAutomationName("UiAutomator2");
options.setAppPackage("com.rp.calculator");
options.setAppActivity("com.rp.calculator.MainActivity");
// 防止每次测试重置应用状态,提升执行速度
options.setNoReset(true);
// 设置命令超时时间
options.setNewCommandTimeout(Duration.ofSeconds(60));
try {
AndroidDriver androidDriver = new AndroidDriver(new URL(APPIUM_SERVER_URL), options);
// 设置全局隐式等待,但建议在具体操作中使用显式等待
androidDriver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));
driver.set(androidDriver);
} catch (MalformedURLException e) {
throw new RuntimeException("Appium Server URL格式错误", e);
}
}
public static void quitDriver() {
if (driver.get() != null) {
driver.get().quit();
driver.remove();
}
}
}
注意:这里使用了
ThreadLocal来存储驱动实例。如果你计划未来实现并行测试(例如,同时测试多个设备或多个测试类),ThreadLocal能确保每个线程拥有自己独立的驱动实例,避免并发冲突。这是从小型脚本过渡到企业级框架的关键一步。
接下来,我们实现页面对象模型(Page Object Model, POM)。POM是UI自动化测试中最重要的设计模式,它将页面元素定位和页面操作封装成独立的类,使测试脚本更简洁,元素定位变更的影响范围最小化。我们为计算器APP创建一个CalculatorPage类。
首先,在pages/elements/目录下创建CalculatorLocators.java,集中管理所有元素定位器:
package com.yourcompany.automation.pages.elements;
import org.openqa.selenium.By;
public class CalculatorLocators {
// 数字按钮
public static final By BUTTON_0 = By.xpath("//android.widget.Button[@text='0']");
public static final By BUTTON_1 = By.xpath("//android.widget.Button[@text='1']");
// ... 其他数字按钮2-9
// 运算符按钮
public static final By BUTTON_ADD = By.xpath("//android.widget.Button[@text='+']");
public static final By BUTTON_SUBTRACT = By.xpath("//android.widget.Button[@text='-']");
public static final By BUTTON_MULTIPLY = By.xpath("//android.widget.Button[@text='×']");
public static final By BUTTON_DIVIDE = By.xpath("//android.widget.Button[@text='÷']");
public static final By BUTTON_EQUALS = By.id("com.rp.calculator:id/eql");
public static final By BUTTON_CLEAR = By.xpath("//android.widget.Button[@text='C']");
// 显示区域
public static final By DISPLAY_EXPRESSION = By.id("com.rp.calculator:id/qusTv");
public static final By DISPLAY_RESULT = By.id("com.rp.calculator:id/ansTv");
// 其他功能按钮
public static final By BUTTON_DECIMAL = By.xpath("//android.widget.Button[@text='.']");
public static final By BUTTON_PERCENT = By.xpath("//android.widget.Button[@text='%']");
}
现在,创建CalculatorPage.java,封装所有与计算器交互的操作:
package com.yourcompany.automation.pages;
import com.yourcompany.automation.pages.elements.CalculatorLocators;
import com.yourcompany.automation.utils.WaitHelper;
import io.appium.java_client.AppiumDriver;
import org.openqa.selenium.WebElement;
public class CalculatorPage {
private final AppiumDriver driver;
private final WaitHelper waitHelper;
public CalculatorPage(AppiumDriver driver) {
this.driver = driver;
this.waitHelper = new WaitHelper(driver);
}
// 基础点击操作
public void clickDigit(int digit) {
if (digit < 0 || digit > 9) {
throw new IllegalArgumentException("数字必须在0-9之间");
}
By locator = getDigitLocator(digit);
click(locator);
}
public void clickOperator(String operator) {
By locator = getOperatorLocator(operator);
click(locator);
}
public void clickEquals() {
click(CalculatorLocators.BUTTON_EQUALS);
}
public void clear() {
click(CalculatorLocators.BUTTON_CLEAR);
}
// 组合操作:输入完整数字(支持多位)
public void enterNumber(String number) {
for (char c : number.toCharArray()) {
if (c == '.') {
click(CalculatorLocators.BUTTON_DECIMAL);
} else {
clickDigit(Character.getNumericValue(c));
}
}
}
// 执行完整计算
public String performCalculation(String num1, String operator, String num2) {
clear(); // 确保计算器在初始状态
enterNumber(num1);
clickOperator(operator);
enterNumber(num2);
clickEquals();
return getResult();
}
// 获取显示结果
public String getExpression() {
WebElement element = waitHelper.waitForElementToBeVisible(CalculatorLocators.DISPLAY_EXPRESSION);
return element.getText();
}
public String getResult() {
WebElement element = waitHelper.waitForElementToBeVisible(CalculatorLocators.DISPLAY_RESULT);
return element.getText();
}
// 私有辅助方法
private void click(By locator) {
WebElement element = waitHelper.waitForElementToBeClickable(locator);
element.click();
}
private By getDigitLocator(int digit) {
switch (digit) {
case 0: return CalculatorLocators.BUTTON_0;
case 1: return CalculatorLocators.BUTTON_1;
case 2: return CalculatorLocators.BUTTON_2;
// ... 3-9
case 9: return CalculatorLocators.BUTTON_9;
default: throw new IllegalArgumentException("不支持的数字: " + digit);
}
}
private By getOperatorLocator(String operator) {
switch (operator) {
case "+": return CalculatorLocators.BUTTON_ADD;
case "-": return CalculatorLocators.BUTTON_SUBTRACT;
case "×": return CalculatorLocators.BUTTON_MULTIPLY;
case "÷": return CalculatorLocators.BUTTON_DIVIDE;
default: throw new IllegalArgumentException("不支持的运算符: " + operator);
}
}
}
这个CalculatorPage类有几个设计亮点:
- 操作封装:将低级的
findElement和click调用封装在click()私有方法中,外部只需关注业务逻辑。 - 等待集成:通过
WaitHelper确保元素可交互后再操作,大幅提升脚本稳定性。 - 错误处理:对非法输入(如数字范围、不支持的运算符)进行了校验和友好提示。
- 组合方法:提供了
performCalculation()这样的高级方法,一行代码即可完成一个完整计算,使测试用例极其简洁。
3. 测试用例设计与数据驱动测试
有了稳固的框架基础,现在我们可以专注于测试逻辑本身。测试用例的设计质量直接决定了自动化测试的价值。我们不应该只测试“快乐路径”,更要关注边界条件、异常场景和用户体验。
让我们先创建一个基础的测试类CalculatorBasicOperationsTest,它继承自我们稍后会创建的BaseTest。这个类将包含计算器的核心功能测试。
package com.yourcompany.automation.tests;
import com.yourcompany.automation.base.BaseTest;
import com.yourcompany.automation.pages.CalculatorPage;
import org.testng.Assert;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
public class CalculatorBasicOperationsTest extends BaseTest {
private CalculatorPage calculatorPage;
@BeforeMethod
public void setUpTest() {
calculatorPage = new CalculatorPage(getDriver());
}
@Test(description = "验证整数加法功能")
public void testIntegerAddition() {
String result = calculatorPage.performCalculation("15", "+", "27");
Assert.assertEquals(result, "42", "15 + 27 应该等于 42");
}
@Test(description = "验证小数乘法功能")
public void testDecimalMultiplication() {
calculatorPage.enterNumber("3.14");
calculatorPage.clickOperator("×");
calculatorPage.enterNumber("2");
calculatorPage.clickEquals();
String result = calculatorPage.getResult();
// 注意浮点数计算可能有精度问题,实际项目中可能需要处理
Assert.assertEquals(result, "6.28", "3.14 × 2 应该等于 6.28");
}
@Test(description = "验证连续运算")
public void testChainedOperations() {
// 测试 5 + 3 × 2,注意计算器通常按输入顺序计算,而非数学优先级
calculatorPage.enterNumber("5");
calculatorPage.clickOperator("+");
calculatorPage.enterNumber("3");
calculatorPage.clickOperator("×");
calculatorPage.enterNumber("2");
calculatorPage.clickEquals();
String result = calculatorPage.getResult();
// 如果是简单计算器:(5+3)×2=16;如果是科学计算器:5+(3×2)=11
// 这里假设是简单计算器
Assert.assertEquals(result, "16", "连续运算结果不符合预期");
}
@Test(description = "验证清除功能")
public void testClearFunction() {
calculatorPage.enterNumber("123");
calculatorPage.clear();
String expression = calculatorPage.getExpression();
String result = calculatorPage.getResult();
Assert.assertTrue(expression.isEmpty() || expression.equals("0"),
"清除后表达式应为空或0");
Assert.assertTrue(result.isEmpty() || result.equals("0"),
"清除后结果应为空或0");
}
}
这些测试用例覆盖了基本场景,但真正的自动化威力在于数据驱动测试(Data-Driven Testing)。当我们需要测试同一功能的多组输入时,手动编写每个测试方法既枯燥又容易出错。TestNG的@DataProvider注解完美解决了这个问题。
让我们创建一个专门测试边界条件和异常场景的测试类CalculatorEdgeCasesTest,并展示数据驱动的强大之处:
package com.yourcompany.automation.tests;
import com.yourcompany.automation.base.BaseTest;
import com.yourcompany.automation.pages.CalculatorPage;
import org.testng.Assert;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
public class CalculatorEdgeCasesTest extends BaseTest {
private CalculatorPage calculatorPage;
@BeforeMethod
public void setUpTest() {
calculatorPage = new CalculatorPage(getDriver());
}
// 数据提供者:零值处理的各种场景
@DataProvider(name = "zeroHandlingData")
public Object[][] provideZeroHandlingData() {
return new Object[][] {
{"0", "+", "983", "983", "零加任何数等于该数"},
{"0", "-", "983", "-983", "零减任何数等于该数的负数"},
{"983", "-", "0", "983", "任何数减零等于该数"},
{"0", "×", "3", "0", "零乘以任何数等于零"},
{"12", "÷", "0", "∞", "任何数除以零应为无穷大或错误"},
{"0", "÷", "12", "0", "零除以任何非零数等于零"}
};
}
@Test(dataProvider = "zeroHandlingData",
description = "数据驱动测试:零值处理的各种边界情况")
public void testZeroHandling(String num1, String operator,
String num2, String expected, String description) {
String actual = calculatorPage.performCalculation(num1, operator, num2);
Assert.assertEquals(actual, expected,
String.format("测试失败:%s。%s %s %s 应该等于 %s,但得到 %s",
description, num1, operator, num2, expected, actual));
}
// 数据提供者:大数运算和溢出测试
@DataProvider(name = "largeNumberData")
public Object[][] provideLargeNumberData() {
return new Object[][] {
{"9999999", "×", "9999999", "99999980000001", "大数乘法"},
{"123456789", "+", "987654321", "1111111110", "大数加法"},
{"1000000000", "-", "1", "999999999", "大数减法"},
// 测试输入长度限制(假设计算器最多显示15位)
{"123456789012345", "+", "1", "123456789012346", "15位数字加法"},
{"999999999999999", "×", "2", "1999999999999998", "接近上限的乘法"}
};
}
@Test(dataProvider = "largeNumberData",
description = "数据驱动测试:大数运算和边界值")
public void testLargeNumberOperations(String num1, String operator,
String num2, String expected, String scenario) {
String actual = calculatorPage.performCalculation(num1, operator, num2);
// 有些计算器可能会截断或科学计数法显示,这里需要灵活断言
if (actual.contains("E") || actual.length() > 15) {
// 如果是科学计数法或超过显示长度,验证计算逻辑而非精确字符串
double actualValue = Double.parseDouble(actual.replace("×10^", "E"));
double expectedValue = Double.parseDouble(expected);
Assert.assertTrue(Math.abs(actualValue - expectedValue) < 0.0001,
String.format("场景'%s':数值计算错误", scenario));
} else {
Assert.assertEquals(actual, expected,
String.format("场景'%s':结果不符合预期", scenario));
}
}
// 测试小数精度
@Test(description = "测试小数运算的精度处理")
public void testDecimalPrecision() {
// 测试经典的小数精度问题:0.1 + 0.2
String result = calculatorPage.performCalculation("0.1", "+", "0.2");
// 不同计算器处理方式不同:可能显示0.3,也可能显示0.30000000000000004
// 这里我们做模糊匹配
if (!result.equals("0.3")) {
double resultValue = Double.parseDouble(result);
Assert.assertTrue(Math.abs(resultValue - 0.3) < 0.0000001,
"0.1 + 0.2 应该近似等于 0.3,实际得到: " + result);
}
}
// 测试连续操作后的状态
@Test(description = "测试连续操作后计算器的状态重置")
public void testStateAfterMultipleOperations() {
// 执行一系列操作
calculatorPage.performCalculation("5", "+", "3");
calculatorPage.clear();
calculatorPage.performCalculation("10", "×", "2");
String finalResult = calculatorPage.getResult();
Assert.assertEquals(finalResult, "20", "状态重置后计算应正常");
// 再次清除并验证
calculatorPage.clear();
String expressionAfterClear = calculatorPage.getExpression();
Assert.assertTrue(expressionAfterClear.isEmpty() || expressionAfterClear.equals("0"),
"多次操作后清除功能应正常工作");
}
}
这个测试类展示了数据驱动测试的几个关键优势:
- 测试覆盖全面:通过数据提供者,我们可以轻松添加新的测试用例,只需在数据数组中添加一行。
- 清晰的测试报告:每个数据行都可以有描述信息,测试失败时能快速定位是哪组数据出了问题。
- 代码复用:相同的测试逻辑可以应用于多组数据,避免了代码重复。
- 维护简便:当测试数据需要更新时,只需修改数据提供者方法,而不需要改动测试逻辑。
4. 高级技巧:等待策略、异常处理与测试报告
一个健壮的自动化测试框架必须妥善处理移动应用的不确定性。网络延迟、应用响应慢、动态内容加载等因素都可能导致元素定位失败。此外,当测试失败时,我们需要足够的信息来诊断问题。
4.1 智能等待策略
Appium测试中最常见的问题就是“元素找不到”。单纯的Thread.sleep()是低效且不可靠的。我们需要实现智能的等待策略。创建一个WaitHelper工具类:
package com.yourcompany.automation.utils;
import io.appium.java_client.AppiumDriver;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.time.Duration;
import java.util.List;
public class WaitHelper {
private final AppiumDriver driver;
private final WebDriverWait wait;
private static final int DEFAULT_TIMEOUT = 15;
private static final int POLLING_INTERVAL = 500; // 毫秒
public WaitHelper(AppiumDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(DEFAULT_TIMEOUT));
this.wait.pollingEvery(Duration.ofMillis(POLLING_INTERVAL));
}
public WebElement waitForElementToBeVisible(By locator) {
return wait.until(ExpectedConditions.visibilityOfElementLocated(locator));
}
public WebElement waitForElementToBeClickable(By locator) {
return wait.until(ExpectedConditions.elementToBeClickable(locator));
}
public Boolean waitForElementToBeInvisible(By locator) {
return wait.until(ExpectedConditions.invisibilityOfElementLocated(locator));
}
public List<WebElement> waitForAllElementsVisible(By locator) {
return wait.until(ExpectedConditions.visibilityOfAllElementsLocatedBy(locator));
}
public Boolean waitForTextToBePresentInElement(By locator, String text) {
return wait.until(ExpectedConditions.textToBePresentInElementLocated(locator, text));
}
// 自定义等待条件:等待元素存在(不一定可见)
public WebElement waitForElementPresence(By locator) {
return wait.until(driver -> {
List<WebElement> elements = driver.findElements(locator);
return elements.isEmpty() ? null : elements.get(0);
});
}
// 带自定义超时的等待
public WebElement waitForElementToBeVisible(By locator, int timeoutSeconds) {
WebDriverWait customWait = new WebDriverWait(driver, Duration.ofSeconds(timeoutSeconds));
customWait.pollingEvery(Duration.ofMillis(POLLING_INTERVAL));
return customWait.until(ExpectedConditions.visibilityOfElementLocated(locator));
}
}
4.2 异常处理与截图
当测试失败时,一张截图往往胜过千言万语。我们创建一个ScreenshotUtil类,并集成到测试监听器中:
package com.yourcompany.automation.utils;
import io.appium.java_client.AppiumDriver;
import io.appium.java_client.screenrecording.ScreenRecordingUploadOptions;
import org.apache.commons.io.FileUtils;
import org.openqa.selenium.OutputType;
import java.io.File;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Base64;
public class ScreenshotUtil {
private final AppiumDriver driver;
public ScreenshotUtil(AppiumDriver driver) {
this.driver = driver;
}
public String takeScreenshot(String testName) {
try {
File srcFile = driver.getScreenshotAs(OutputType.FILE);
String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
String fileName = String.format("screenshot_%s_%s.png", testName, timestamp);
File destFile = new File("test-output/screenshots/" + fileName);
FileUtils.copyFile(srcFile, destFile);
return destFile.getAbsolutePath();
} catch (IOException e) {
System.err.println("截图失败: " + e.getMessage());
return null;
}
}
// 录制屏幕视频(Appium 1.8.0+ 支持)
public void startScreenRecording() {
driver.startRecordingScreen();
}
public String stopAndSaveScreenRecording(String testName) {
String base64String = driver.stopRecordingScreen();
byte[] data = Base64.getDecoder().decode(base64String);
String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
String fileName = String.format("recording_%s_%s.mp4", testName, timestamp);
File destFile = new File("test-output/recordings/" + fileName);
try {
FileUtils.writeByteArrayToFile(destFile, data);
return destFile.getAbsolutePath();
} catch (IOException e) {
System.err.println("保存录屏失败: " + e.getMessage());
return null;
}
}
}
4.3 测试监听器与报告生成
TestNG的监听器接口允许我们在测试生命周期的各个阶段插入自定义逻辑。我们可以创建一个监听器来自动截图、记录日志并生成丰富的测试报告:
package com.yourcompany.automation.listeners;
import com.aventstack.extentreports.ExtentReports;
import com.aventstack.extentreports.ExtentTest;
import com.aventstack.extentreports.Status;
import com.aventstack.extentreports.markuputils.ExtentColor;
import com.aventstack.extentreports.markuputils.MarkupHelper;
import com.aventstack.extentreports.reporter.ExtentSparkReporter;
import com.aventstack.extentreports.reporter.configuration.Theme;
import com.yourcompany.automation.base.DriverManager;
import com.yourcompany.automation.utils.ScreenshotUtil;
import org.testng.ITestContext;
import org.testng.ITestListener;
import org.testng.ITestResult;
import java.text.SimpleDateFormat;
import java.util.Date;
public class TestListener implements ITestListener {
private static ExtentReports extent;
private static ThreadLocal<ExtentTest> test = new ThreadLocal<>();
private ScreenshotUtil screenshotUtil;
@Override
public void onStart(ITestContext context) {
String timeStamp = new SimpleDateFormat("yyyy.MM.dd.HH.mm.ss").format(new Date());
String reportName = "Test-Report-" + timeStamp + ".html";
ExtentSparkReporter sparkReporter = new ExtentSparkReporter("test-output/" + reportName);
sparkReporter.config().setDocumentTitle("Appium自动化测试报告");
sparkReporter.config().setReportName("计算器APP测试报告");
sparkReporter.config().setTheme(Theme.DARK);
sparkReporter.config().setEncoding("utf-8");
extent = new ExtentReports();
extent.attachReporter(sparkReporter);
extent.setSystemInfo("测试环境", "Android模拟器");
extent.setSystemInfo("应用", "计算器APP");
extent.setSystemInfo("测试人员", "自动化测试团队");
}
@Override
public void onTestStart(ITestResult result) {
ExtentTest extentTest = extent.createTest(result.getMethod().getMethodName());
extentTest.assignCategory(result.getTestClass().getRealClass().getSimpleName());
test.set(extentTest);
// 初始化截图工具
screenshotUtil = new ScreenshotUtil(DriverManager.getDriver());
// 开始录屏
screenshotUtil.startScreenRecording();
}
@Override
public void onTestSuccess(ITestResult result) {
test.get().log(Status.PASS, MarkupHelper.createLabel("测试通过", ExtentColor.GREEN));
test.get().log(Status.INFO, "测试方法: " + result.getMethod().getMethodName());
test.get().log(Status.INFO, "描述: " + result.getMethod().getDescription());
// 保存录屏
String videoPath = screenshotUtil.stopAndSaveScreenRecording(result.getMethod().getMethodName());
if (videoPath != null) {
test.get().addScreenCaptureFromPath(videoPath, "测试执行录屏");
}
}
@Override
public void onTestFailure(ITestResult result) {
test.get().log(Status.FAIL, MarkupHelper.createLabel("测试失败", ExtentColor.RED));
test.get().log(Status.INFO, "失败原因: " + result.getThrowable().getMessage());
// 失败时截图
String screenshotPath = screenshotUtil.takeScreenshot(result.getMethod().getMethodName());
if (screenshotPath != null) {
test.get().addScreenCaptureFromPath(screenshotPath, "失败时截图");
}
// 保存录屏
String videoPath = screenshotUtil.stopAndSaveScreenRecording(result.getMethod().getMethodName());
if (videoPath != null) {
test.get().addScreenCaptureFromPath(videoPath, "失败过程录屏");
}
// 记录堆栈信息
test.get().fail(result.getThrowable());
}
@Override
public void onTestSkipped(ITestResult result) {
test.get().log(Status.SKIP, MarkupHelper.createLabel("测试跳过", ExtentColor.YELLOW));
test.get().log(Status.INFO, "跳过原因: " + result.getThrowable().getMessage());
}
@Override
public void onFinish(ITestContext context) {
extent.flush();
DriverManager.quitDriver();
}
public static ExtentTest getTest() {
return test.get();
}
}
4.4 完整的BaseTest类
现在,让我们整合所有组件,创建最终的BaseTest类,作为所有测试类的基类:
package com.yourcompany.automation.base;
import com.yourcompany.automation.listeners.TestListener;
import io.appium.java_client.AppiumDriver;
import org.testng.annotations.AfterClass;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Listeners;
@Listeners(TestListener.class)
public class BaseTest {
protected AppiumDriver driver;
@BeforeClass(alwaysRun = true)
public void setUpClass() {
driver = DriverManager.getDriver();
// 这里可以添加一些全局的前置操作,比如跳过引导页、处理权限弹窗等
handleInitialPopups();
}
@AfterMethod(alwaysRun = true)
public void tearDownMethod() {
// 每个测试方法执行后,可以重置应用状态或返回主页
// 这取决于你的测试需求,有时需要保持状态连续性
// driver.resetApp(); // 重置应用
// 或者导航回首页
// driver.startActivity("com.rp.calculator", "com.rp.calculator.MainActivity");
}
@AfterClass(alwaysRun = true)
public void tearDownClass() {
// DriverManager.quitDriver() 已在监听器的onFinish中调用
// 这里可以添加一些清理工作
}
protected AppiumDriver getDriver() {
return driver;
}
private void handleInitialPopups() {
// 处理可能出现的权限弹窗、更新提示等
// 这是一个通用方法,具体实现取决于被测应用
try {
// 示例:等待3秒看是否有弹窗出现
Thread.sleep(3000);
// 这里可以添加代码来点击"允许"、"确定"或"跳过"按钮
// 实际项目中需要根据具体应用调整
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
4.5 配置文件与测试套件
最后,我们需要一个配置文件来管理环境变量和测试参数,以及一个TestNG XML文件来定义测试套件。
src/test/resources/config.properties:
# Appium 服务器配置
appium.server.url=http://localhost:4723
# 设备配置
platform.name=Android
platform.version=13.0
device.name=Pixel_6_Pro_API_33
automation.name=UiAutomator2
# 应用配置
app.package=com.rp.calculator
app.activity=com.rp.calculator.MainActivity
app.noreset=true
# 超时配置
implicit.wait.timeout=10
explicit.wait.timeout=15
page.load.timeout=30
# 报告配置
report.title=计算器APP自动化测试报告
report.theme=dark
src/test/resources/testng.xml:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd">
<suite name="Calculator App Test Suite" parallel="tests" thread-count="2">
<test name="基础运算测试" preserve-order="true">
<classes>
<class name="com.yourcompany.automation.tests.CalculatorBasicOperationsTest"/>
</classes>
</test>
<test name="边界条件测试" preserve-order="true">
<classes>
<class name="com.yourcompany.automation.tests.CalculatorEdgeCasesTest"/>
</classes>
</test>
<listeners>
<listener class-name="com.yourcompany.automation.listeners.TestListener"/>
</listeners>
</suite>
这个测试套件配置支持并行测试(parallel="tests"),两个测试类可以在不同的线程中同时运行,显著缩短整体执行时间。thread-count="2"指定了最大线程数,你可以根据机器性能调整。
5. 框架优化与持续集成
至此,我们已经构建了一个功能完整的Appium+Java测试框架。但在实际企业项目中,我们还需要考虑更多工程化方面的优化。
5.1 参数化配置与多环境支持
硬编码的配置不利于维护和跨环境部署。我们可以创建一个ConfigReader类来读取外部配置文件,并支持不同环境(开发、测试、生产)的切换:
package com.yourcompany.automation.utils;
import org.apache.commons.configuration2.Configuration;
import org.apache.commons.configuration2.builder.fluent.Configurations;
import org.apache.commons.configuration2.ex.ConfigurationException;
import java.io.File;
public class ConfigReader {
private static Configuration config;
static {
try {
String env = System.getProperty("test.env", "dev"); // 默认为dev环境
Configurations configs = new Configurations();
config = configs.properties(new File("src/test/resources/config-" + env + ".properties"));
} catch (ConfigurationException e) {
throw new RuntimeException("加载配置文件失败", e);
}
}
public static String getProperty(String key) {
return config.getString(key);
}
public static String getProperty(String key, String defaultValue) {
return config.getString(key, defaultValue);
}
public static int getIntProperty(String key) {
return config.getInt(key);
}
public static boolean getBooleanProperty(String key) {
return config.getBoolean(key);
}
}
然后创建不同环境的配置文件:
config-dev.properties- 开发环境config-test.properties- 测试环境config-staging.properties- 预发布环境
运行测试时,通过JVM参数指定环境:mvn test -Dtest.env=test
5.2 集成Allure报告
虽然ExtentReports已经不错,但Allure报告在美观度和交互性上更胜一筹。添加Allure支持很简单:
在pom.xml中添加依赖:
<dependency>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-testng</artifactId>
<version>2.24.0</version>
</dependency>
添加Allure插件:
<plugin>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-maven</artifactId>
<version>2.12.0</version>
</plugin>
运行测试后生成报告:mvn allure:serve
5.3 集成Jenkins实现CI/CD
自动化测试的真正价值在于持续集成。下面是一个简单的Jenkins Pipeline脚本示例:
pipeline {
agent any
tools {
maven 'Maven-3.8.5'
jdk 'JDK-17'
}
stages {
stage('Checkout') {
steps {
git branch: 'main', url: 'https://github.com/yourcompany/calculator-test-framework.git'
}
}
stage('Start Appium Server') {
steps {
script {
// 使用Docker启动Appium服务器
sh 'docker run -d --name appium-server -p 4723:4723 --privileged appium/appium:latest'
// 等待服务器启动
sleep 30
}
}
}
stage('Run Tests') {
steps {
script {
// 运行测试并生成Allure报告
sh 'mvn clean test -Dtest.env=ci'
}
}
post {
always {
// 无论测试结果如何,都生成报告
allure includeProperties: false, jdk: '', results: [[path: 'target/allure-results']]
}
}
}
stage('Stop Appium Server') {
steps {
script {
sh 'docker stop appium-server'
sh 'docker rm appium-server'
}
}
}
}
post {
always {
// 清理工作空间
cleanWs()
}
success {
// 测试成功时发送通知
emailext (
subject: "✅ 自动化测试通过: ${env.JOB_NAME} #${env.BUILD_NUMBER}",
body: "所有测试用例执行成功!\n查看详细报告: ${env.BUILD_URL}allure/",
to: 'team@yourcompany.com'
)
}
failure {
// 测试失败时发送通知
emailext (
subject: "❌ 自动化测试失败: ${env.JOB_NAME} #${env.BUILD_NUMBER}",
body: "有测试用例执行失败!\n查看详细报告: ${env.BUILD_URL}allure/",
to: 'team@yourcompany.com'
)
}
}
}
5.4 性能优化建议
随着测试用例数量的增加,执行时间可能成为瓶颈。以下是一些优化建议:
- 测试并行化:如前面所示,在
testng.xml中配置parallel="tests"或parallel="methods"。 - 使用Appium的
noReset和fullReset能力:对于不需要每次重置的应用,使用noReset=true可以大幅减少测试启动时间。 - 截图优化:只在失败时截图,成功时只录屏或记录关键步骤。
- 设备农场:对于大型项目,考虑使用AWS Device Farm、BrowserStack或Sauce Labs等云设备平台进行并行测试。
- 测试用例优先级:为测试用例标注优先级,在快速反馈的CI流水线中只运行高优先级测试,完整回归在夜间执行。
5.5 常见问题排查
在实际项目中,你可能会遇到以下问题:
问题1:元素定位不稳定,有时能找到有时找不到
解决方案:使用WaitHelper中的显式等待,避免使用固定的Thread.sleep()。检查元素是否在WebView中,如果是,需要切换context。
问题2:测试在CI服务器上失败,但在本地能通过 解决方案:确保CI环境与本地环境一致。使用Docker容器运行Appium Server和模拟器。增加超时时间以应对CI服务器性能差异。
问题3:测试报告太庞大,难以分析 解决方案:合理使用测试分组,为不同的测试场景创建不同的测试套件。在报告中只包含必要的截图和日志。
问题4:测试数据管理混乱 解决方案:将测试数据外部化,使用JSON、YAML或Excel文件存储测试数据。创建数据工厂类来管理测试数据的生成和清理。
我在多个实际项目中应用这套框架后,发现最耗时的往往不是编写测试用例本身,而是调试环境问题和分析测试失败原因。因此,我强烈建议在项目初期就投入时间搭建完善的日志和报告系统,这会在后期节省大量排查时间。另外,不要追求100%的UI自动化覆盖率,优先自动化那些核心业务流程和高频使用场景,对于频繁变化的UI部分,可以考虑更稳定的API测试或单元测试作为补充。
更多推荐


所有评论(0)