跳到主要内容

移动应用测试

移动应用测试是确保移动应用在各种设备、操作系统版本和网络条件下正常工作的关键过程。由于移动设备碎片化严重,移动应用测试比传统 Web 应用测试更具挑战性。

移动应用测试概述

移动应用测试需要考虑多种因素:不同的设备型号、屏幕尺寸、操作系统版本、网络条件、电池状态等。有效的移动测试策略需要覆盖这些变量。

移动测试的挑战

挑战说明
设备碎片化数千种设备型号、屏幕尺寸、分辨率组合
操作系统版本不同的 Android/iOS 版本行为可能不同
网络条件2G/3G/4G/5G/WiFi,弱网、断网场景
电池和性能低电量模式、后台运行、内存压力
用户交互触摸、手势、语音、传感器
权限管理相机、定位、存储等权限的处理

测试金字塔

移动应用的测试金字塔与传统应用略有不同:

建议比例:单元测试 70%、集成测试 20%、UI/E2E 测试 10%

Android 测试

Android 提供了完整的测试框架体系,从单元测试到 UI 测试都有官方支持。

测试框架概览

框架类型运行环境特点
JUnit单元测试本地 JVM标准 Java 测试框架
Robolectric单元测试本地 JVM模拟 Android 框架
EspressoUI 测试模拟器/真机Google 官方 UI 测试框架
UI AutomatorUI 测试模拟器/真机跨应用 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 测试由三个基本部分组成:

  1. ViewMatchers - 查找 UI 元素
  2. ViewActions - 对 UI 元素执行操作
  3. 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 官方单元测试框架
XCUITestUI 测试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资源 IDAppiumBy.ID, "com.app:id/button"
Accessibility ID无障碍标识AppiumBy.ACCESSIBILITY_ID, "login"
Class Name类名AppiumBy.CLASS_NAME, "android.widget.Button"
XPathXPath 表达式AppiumBy.XPATH, "//android.widget.Button[@text='登录']"
UIAutomatorAndroid UI 选择器AppiumBy.ANDROID_UIAUTOMATOR, 'new UiSelector().text("登录")'
iOS PredicateiOS 谓词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 LabGoogle 提供,集成度高
AWS Device FarmAWS 生态,支持多框架
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 测试
  • 使用本地单元测试替代仪器测试
  • 并行运行测试

参考资源

下一步

继续学习: