移动应用测试
移动应用测试是确保移动应用在各种设备、操作系统版本和网络条件下正常工作的关键过程。由于移动设备碎片化严重,移动应用测试比传统 Web 应用测试更具挑战性。
移动应用测试概述
移动应用测试需要考虑多种因素:不同的设备型号、屏幕尺寸、操作系统版本、网络条件、电池状态等。有效的移动测试策略需要覆盖这些变量。
移动测试的挑战
| 挑战 | 说明 |
|---|---|
| 设备碎片化 | 数千种设备型号、屏幕尺寸、分辨率组合 |
| 操作系统版本 | 不同的 Android/iOS 版本行为可能不同 |
| 网络条件 | 2G/3G/4G/5G/WiFi,弱网、断网场景 |
| 电池和性能 | 低电量模式、后台运行、内存压力 |
| 用户交互 | 触摸、手势、语音、传感器 |
| 权限管理 | 相机、定位、存储等权限的处理 |
测试金字塔
移动应用的测试金字塔与传统应用略有不同:
建议比例:单元测试 70%、集成测试 20%、UI/E2E 测试 10%
Android 测试
Android 提供了完整的测试框架体系,从单元测试到 UI 测试都有官方支持。
测试框架概览
| 框架 | 类型 | 运行环境 | 特点 |
|---|---|---|---|
| JUnit | 单元测试 | 本地 JVM | 标准 Java 测试框架 |
| Robolectric | 单元测试 | 本地 JVM | 模拟 Android 框架 |
| Espresso | UI 测试 | 模拟器/真机 | Google 官方 UI 测试框架 |
| UI Automator | UI 测试 | 模拟器/真机 | 跨应用 UI 测试 |
| AndroidX Test | 测试工具 | 多环境 | 统一的测试 API |
项目结构
app/
├── src/
│ ├── main/ # 生产代码
│ │ └── java/
│ ├── test/ # 本地单元测试
│ │ └── java/
│ └── androidTest/ # 仪器测试(UI测试)
│ └── java/
└── build.gradle
Espresso UI 测试
Espresso 是 Android 官方推荐的 UI 测试框架,它提供简洁的 API 和自动同步机制。
基本配置
// build.gradle
dependencies {
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
}
基本 Espresso 测试
// LoginActivityTest.kt
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {
// 启动 Activity
@get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
@Test
fun loginWithValidCredentials_succeeds() {
// 输入用户名
onView(withId(R.id.username_edit_text))
.perform(typeText("testuser"))
// 输入密码
onView(withId(R.id.password_edit_text))
.perform(typeText("password123"))
// 关闭软键盘
closeSoftKeyboard()
// 点击登录按钮
onView(withId(R.id.login_button))
.perform(click())
// 验证跳转到主页
onView(withId(R.id.welcome_text))
.check(matches(isDisplayed()))
}
@Test
fun loginWithEmptyUsername_showsError() {
// 不输入用户名,直接点击登录
onView(withId(R.id.login_button))
.perform(click())
// 验证错误提示
onView(withId(R.id.username_input_layout))
.check(matches(hasErrorText("请输入用户名")))
}
}
Espresso 基本组件
Espresso 测试由三个基本部分组成:
- ViewMatchers - 查找 UI 元素
- ViewActions - 对 UI 元素执行操作
- ViewAssertions - 验证 UI 元素状态
// ViewMatchers - 查找视图
onView(withId(R.id.button))
onView(withText("登录"))
onView(withHint("请输入用户名"))
onView(allOf(withId(R.id.button), withText("提交")))
onView(isDisplayed())
onView(isEnabled())
// ViewActions - 执行操作
.perform(click())
.perform(typeText("hello"))
.perform(scrollTo())
.perform(swipeLeft())
.perform(replaceText("new text"))
.perform(clearText())
// ViewAssertions - 断言
.check(matches(isDisplayed()))
.check(matches(withText("成功")))
.check(matches(not(isEnabled())))
.check(doesNotExist())
RecyclerView 测试
@Test
fun clickOnItem_navigatesToDetail() {
// 点击 RecyclerView 中的特定项
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(
0, click()
))
// 验证跳转到详情页
onView(withId(R.id.detail_title))
.check(matches(isDisplayed()))
}
@Test
fun scrollAndClickItem() {
// 滚动到特定位置并点击
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(10))
.perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(
10, click()
))
}
Intent 测试
@RunWith(AndroidJUnit4::class)
class IntentTest {
@get:Rule
val intentsTestRule = IntentsTestRule(MainActivity::class.java)
@Test
fun clickButton_startsBrowserIntent() {
// 准备:模拟 Intent 结果
intending(hasAction(Intent.ACTION_VIEW))
.respondWith(Instrumentation.ActivityResult(0, null))
// 执行:点击打开浏览器的按钮
onView(withId(R.id.open_browser_button))
.perform(click())
// 验证:检查是否发送了正确的 Intent
intended(allOf(
hasAction(Intent.ACTION_VIEW),
hasData(Uri.parse("https://example.com"))
))
}
}
本地单元测试
使用 Robolectric 可以在本地 JVM 上运行需要 Android 框架的测试,速度更快。
// build.gradle
testImplementation 'org.robolectric:robolectric:4.11.1'
@RunWith(RobolectricTestRunner::class)
class CalculatorTest {
@Test
fun add_twoNumbers_returnsSum() {
val calculator = Calculator()
val result = calculator.add(2, 3)
assertEquals(5, result)
}
@Test
fun formatCurrency_usesLocaleFormat() {
val formatter = CurrencyFormatter()
val result = formatter.format(1000.0)
assertTrue(result.contains("1,000"))
}
}
iOS 测试
iOS 提供了 XCTest 和 XCUITest 作为官方测试框架。
测试框架概览
| 框架 | 类型 | 特点 |
|---|---|---|
| XCTest | 单元测试 | Apple 官方单元测试框架 |
| XCUITest | UI 测试 | Apple 官方 UI 测试框架 |
| Quick/Nimble | 单元测试 | BDD 风格测试框架 |
XCTest 单元测试
// CalculatorTests.swift
import XCTest
@testable import MyApp
class CalculatorTests: XCTestCase {
var calculator: Calculator!
override func setUp() {
super.setUp()
calculator = Calculator()
}
override func tearDown() {
calculator = nil
super.tearDown()
}
func testAdd_twoPositiveNumbers_returnsSum() {
let result = calculator.add(2, 3)
XCTAssertEqual(result, 5)
}
func testDivide_byZero_throwsError() {
XCTAssertThrowsError(try calculator.divide(10, 0)) { error in
XCTAssertEqual(error as? CalculatorError, .divisionByZero)
}
}
func testPerformance_addLargeNumbers() {
measure {
for _ in 0..<1000 {
_ = calculator.add(1000000, 1000000)
}
}
}
}
XCUITest UI 测试
XCUITest 是 Apple 官方的 UI 测试框架,与 Xcode 深度集成。
基本 UI 测试
// LoginTests.swift
import XCTest
class LoginTests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["--uitesting"]
app.launch()
}
func testLogin_withValidCredentials_succeeds() {
// 输入用户名
let usernameField = app.textFields["username"]
usernameField.tap()
usernameField.typeText("testuser")
// 输入密码
let passwordField = app.secureTextFields["password"]
passwordField.tap()
passwordField.typeText("password123")
// 点击登录按钮
let loginButton = app.buttons["login"]
loginButton.tap()
// 验证登录成功
let welcomeLabel = app.staticTexts["welcome"]
XCTAssertTrue(welcomeLabel.waitForExistence(timeout: 5))
XCTAssertTrue(welcomeLabel.label.contains("欢迎"))
}
func testLogin_withEmptyFields_showsError() {
// 直接点击登录
app.buttons["login"].tap()
// 验证错误提示
let errorLabel = app.staticTexts["error"]
XCTAssertTrue(errorLabel.exists)
XCTAssertEqual(errorLabel.label, "请输入用户名和密码")
}
}
表格和列表测试
func testTableView_displaysCorrectItems() {
// 获取表格
let tableView = app.tables["items_table"]
// 验证表格存在
XCTAssertTrue(tableView.exists)
// 验证单元格数量
let cells = tableView.cells
XCTAssertGreaterThanOrEqual(cells.count, 3)
// 点击第一个单元格
let firstCell = cells.element(boundBy: 0)
firstCell.tap()
// 验证跳转到详情页
XCTAssertTrue(app.staticTexts["detail_title"].exists)
}
func testSwipeToDelete_removesItem() {
let tableView = app.tables["items_table"]
let firstCell = tableView.cells.element(boundBy: 0)
// 左滑删除
firstCell.swipeLeft()
// 点击删除按钮
app.buttons["Delete"].tap()
// 验证单元格被删除
XCTAssertFalse(firstCell.exists)
}
异步操作测试
func testAsyncLoading_showsContent() {
// 触发加载
app.buttons["refresh"].tap()
// 等待加载指示器消失
let loadingIndicator = app.activityIndicators["loading"]
let loadingGone = NSPredicate(format: "exists == false")
expectation(for: loadingGone, evaluatedWith: loadingIndicator, handler: nil)
waitForExpectations(timeout: 10, handler: nil)
// 验证内容显示
let content = app.staticTexts["content"]
XCTAssertTrue(content.exists)
}
跨平台测试:Appium
Appium 是一个开源的跨平台移动应用自动化测试框架,支持 iOS 和 Android。
Appium 架构
Appium 安装配置
# 安装 Node.js 后安装 Appium
npm install -g appium
# 安装驱动
appium driver install uiautomator2 # Android
appium driver install xcuitest # iOS
# 启动 Appium 服务器
appium
Python Appium 测试
# test_login.py
import pytest
from appium import webdriver
from appium.options.android import UiAutomator2Options
from appium.webdriver.common.appiumby import AppiumBy
class TestLogin:
"""登录测试"""
@pytest.fixture
def driver(self):
# Android 配置
options = UiAutomator2Options()
options.platform_name = "Android"
options.device_name = "emulator-5554"
options.app = "/path/to/app.apk"
options.automation_name = "UiAutomator2"
driver = webdriver.Remote(
"http://localhost:4723",
options=options
)
yield driver
driver.quit()
def test_login_with_valid_credentials(self, driver):
"""测试正常登录"""
# 找到用户名输入框并输入
username = driver.find_element(
AppiumBy.ID,
"com.example.app:id/username"
)
username.send_keys("testuser")
# 找到密码输入框并输入
password = driver.find_element(
AppiumBy.ID,
"com.example.app:id/password"
)
password.send_keys("password123")
# 点击登录按钮
login_button = driver.find_element(
AppiumBy.ID,
"com.example.app:id/login_button"
)
login_button.click()
# 验证登录成功
welcome = driver.find_element(
AppiumBy.ID,
"com.example.app:id/welcome_text"
)
assert welcome.is_displayed()
def test_login_with_empty_fields(self, driver):
"""测试空字段登录"""
# 直接点击登录
login_button = driver.find_element(
AppiumBy.ID,
"com.example.app:id/login_button"
)
login_button.click()
# 验证错误提示
error = driver.find_element(
AppiumBy.ID,
"com.example.app:id/error_text"
)
assert "请输入" in error.text
iOS Appium 测试
from appium import webdriver
from appium.options.ios import XCUITestOptions
class TestiOSLogin:
@pytest.fixture
def driver(self):
options = XCUITestOptions()
options.platform_name = "iOS"
options.device_name = "iPhone 14"
options.platform_version = "16.4"
options.app = "/path/to/app.app"
options.automation_name = "XCUITest"
driver = webdriver.Remote(
"http://localhost:4723",
options=options
)
yield driver
driver.quit()
def test_ios_login(self, driver):
"""iOS 登录测试"""
# 使用 accessibility ID 定位元素
username = driver.find_element(
AppiumBy.ACCESSIBILITY_ID,
"username_field"
)
username.send_keys("testuser")
password = driver.find_element(
AppiumBy.ACCESSIBILITY_ID,
"password_field"
)
password.send_keys("password123")
driver.find_element(
AppiumBy.ACCESSIBILITY_ID,
"login_button"
).click()
# 验证
welcome = driver.find_element(
AppiumBy.ACCESSIBILITY_ID,
"welcome_label"
)
assert welcome.is_displayed()
Appium 定位策略
| 策略 | 描述 | 示例 |
|---|---|---|
| ID | 资源 ID | AppiumBy.ID, "com.app:id/button" |
| Accessibility ID | 无障碍标识 | AppiumBy.ACCESSIBILITY_ID, "login" |
| Class Name | 类名 | AppiumBy.CLASS_NAME, "android.widget.Button" |
| XPath | XPath 表达式 | AppiumBy.XPATH, "//android.widget.Button[@text='登录']" |
| UIAutomator | Android UI 选择器 | AppiumBy.ANDROID_UIAUTOMATOR, 'new UiSelector().text("登录")' |
| iOS Predicate | iOS 谓词 | AppiumBy.IOS_PREDICATE, 'name == "login"' |
测试类型详解
单元测试
测试独立的函数、方法或类,不依赖外部系统。
// Android 单元测试
class CalculatorTest {
private val calculator = Calculator()
@Test
fun add_positiveNumbers_returnsCorrectSum() {
assertEquals(5, calculator.add(2, 3))
}
@Test
fun divide_byZero_throwsException() {
assertThrows(IllegalArgumentException::class.java) {
calculator.divide(10, 0)
}
}
}
集成测试
测试多个组件的协作。
// Android 集成测试
@RunWith(AndroidJUnit4::class)
class UserRepositoryTest {
private lateinit var database: AppDatabase
private lateinit var repository: UserRepository
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(
context,
AppDatabase::class.java
).build()
repository = UserRepository(database.userDao())
}
@Test
fun saveAndRetrieveUser_worksCorrectly() = runBlocking {
val user = User(id = 1, name = "张三")
repository.save(user)
val retrieved = repository.findById(1)
assertEquals("张三", retrieved?.name)
}
@After
fun teardown() {
database.close()
}
}
UI 测试
测试用户界面和交互流程。
@Test
fun completeOrderFlow_worksEndToEnd() {
// 1. 登录
onView(withId(R.id.username)).perform(typeText("user"))
onView(withId(R.id.password)).perform(typeText("pass"))
onView(withId(R.id.login)).perform(click())
// 2. 浏览商品
onView(withId(R.id.product_list)).perform(swipeUp())
// 3. 添加到购物车
onView(allOf(withId(R.id.add_to_cart), isDisplayed()))
.perform(click())
// 4. 查看购物车
onView(withId(R.id.cart_icon)).perform(click())
// 5. 结算
onView(withId(R.id.checkout)).perform(click())
// 6. 验证订单成功
onView(withId(R.id.order_success))
.check(matches(isDisplayed()))
}
移动测试最佳实践
1. 使用测试标签
// Android
@SdkSuppress(minSdkVersion = 26)
@LargeTest
@Test
fun complexUITest() {
// ...
}
// 运行特定标签的测试
// adb shell am instrument -e annotation LargeTest
2. 模拟器和真机结合
- 单元测试:在本地 JVM 运行,最快
- 集成测试:在模拟器运行,较快
- UI 测试:模拟器开发调试,真机验证
3. 管理测试数据
// 使用测试构建变体
android {
buildTypes {
getByName("debug") {
buildConfigField("String", "TEST_USER", "\"[email protected]\"")
}
}
}
// 或使用测试资源
// src/androidTest/resources/test_data.json
4. 稳定的 UI 测试
// 使用 IdlingResource 等待异步操作
@RunWith(AndroidJUnit4::class)
class AsyncTest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Before
fun setup() {
IdlingRegistry.getInstance().register(
(activityRule.scenario as ActivityScenario<MainActivity>)
.getIdlingResource()
)
}
@Test
fun asyncOperation_updatesUI() {
// Espresso 会自动等待 IdlingResource
onView(withId(R.id.result))
.check(matches(withText("Success")))
}
}
5. 使用屏幕录制
// Android 测试失败时自动截图
@Rule
@JvmField
val screenshotRule = ScreenshotTestRule()
class ScreenshotTestRule : TestWatcher() {
override fun failed(e: Throwable?, description: Description?) {
val screenshot = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
.takeScreenshot()
// 保存截图...
}
}
6. 云测试平台
使用云测试平台可以在大量真机上测试:
| 平台 | 特点 |
|---|---|
| Firebase Test Lab | Google 提供,集成度高 |
| AWS Device Farm | AWS 生态,支持多框架 |
| BrowserStack | 真机云,支持 Appium |
| Sauce Labs | 企业级,详细报告 |
常见问题解决
1. 测试不稳定
原因:异步操作未完成、网络延迟、动画干扰
解决:
- 使用 IdlingResource 等待异步完成
- 增加适当的等待时间
- 禁用动画(测试构建)
// 禁用动画
// 在测试的 Application 类中
override fun onCreate() {
if (BuildConfig.DEBUG) {
disableAnimations()
}
}
2. 元素定位失败
原因:UI 结构变化、动态 ID、列表复用
解决:
- 使用稳定的定位器(accessibility ID)
- 添加 contentDescription
- 使用相对定位
// 为元素添加 contentDescription
<Button
android:id="@+id/login"
android:contentDescription="@string/login_button"
... />
// 测试中使用
onView(withContentDescription(R.string.login_button))
.perform(click())
3. 测试运行缓慢
解决:
- 减少不必要的 UI 测试
- 使用本地单元测试替代仪器测试
- 并行运行测试
参考资源
- Android Testing Guide - Android 官方测试指南
- Espresso 文档 - Espresso UI 测试
- XCTest 文档 - Apple 官方测试框架
- Appium 文档 - Appium 官方文档
- Firebase Test Lab - Google 云测试平台
下一步
继续学习: