name: 移动测试框架 description: 移动测试框架技能 version: 1.0.0 category: 质量保证 slug: mobile-testing status: active
移动测试框架技能
概览
这项技能提供了跨平台移动测试框架的全面专业知识。它支持使用Detox和Maestro进行端到端测试,使用XCUITest和Espresso进行原生测试,以及使用Appium进行跨平台测试。
允许使用的工具
bash- 执行测试命令和框架CLIsread- 分析测试文件和配置write- 生成测试用例和配置edit- 更新现有测试glob- 搜索测试文件grep- 在测试代码中搜索模式
能力
Detox (React Native)
-
配置
- 设置Detox配置
- 配置iOS和Android构建
- 设置设备/模拟器目标
- 配置测试运行器(Jest)
-
测试编写
- 编写元素匹配器
- 实施动作(点击、输入、滚动)
- 配置期望
- 处理同步
-
高级特性
- 模拟原生模块
- 处理权限
- 配置网络模拟
- 实施视觉回归
Maestro
-
流程配置
- 编写YAML测试流程
- 配置应用启动
- 设置设备目标
- 处理环境变量
-
动作和断言
- 点击、输入、滑动手势
- 断言元素可见性
- 截图
- 运行JavaScript断言
XCUITest (iOS)
-
测试设置
- 配置测试方案
- 设置测试计划
- 配置设备目标
- 处理启动参数
-
UI测试
- 使用XCUIElement进行元素查询
- 动作(点击、滑动、捏合)
- 使用辅助功能标识符
- 键盘处理
Espresso (Android)
-
测试配置
- 设置仪器测试
- 配置测试运行器
- 处理Hilt注入
- 配置Compose测试
-
UI测试
- ViewMatchers和ViewActions
- Compose语义测试
- IdlingResources用于异步
- 意图验证
Appium
- 跨平台测试
- 配置能力
- 设置驱动会话
- 处理多个平台
- 配置云测试
设备农场
- 云测试
- AWS设备农场集成
- Firebase测试实验室设置
- BrowserStack配置
- 测试分发
目标流程
这项技能与以下流程集成:
mobile-testing-strategy.js- 测试策略实施mobile-accessibility-implementation.js- 可访问性测试mobile-security-implementation.js- 安全测试
依赖
必需
- Node.js(用于Detox、Maestro)
- Xcode(用于XCUITest)
- Android Studio(用于Espresso)
- 平台特定的SDK
可选
- Appium
- 设备农场账户
- CI/CD平台
配置
Detox配置
// .detoxrc.js
module.exports = {
testRunner: {
args: {
$0: 'jest',
config: 'e2e/jest.config.js',
},
jest: {
setupTimeout: 120000,
},
},
apps: {
'ios.debug': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/MyApp.app',
build: 'xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build',
},
'ios.release': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/MyApp.app',
build: 'xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Release -sdk iphonesimulator -derivedDataPath ios/build',
},
'android.debug': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug',
reversePorts: [8081],
},
'android.release': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',
build: 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release',
},
},
devices: {
simulator: {
type: 'ios.simulator',
device: { type: 'iPhone 15 Pro' },
},
emulator: {
type: 'android.emulator',
device: { avdName: 'Pixel_7_API_34' },
},
},
configurations: {
'ios.sim.debug': {
device: 'simulator',
app: 'ios.debug',
},
'ios.sim.release': {
device: 'simulator',
app: 'ios.release',
},
'android.emu.debug': {
device: 'emulator',
app: 'android.debug',
},
'android.emu.release': {
device: 'emulator',
app: 'android.release',
},
},
};
使用示例
Detox测试
// e2e/login.test.ts
import { device, element, by, expect } from 'detox';
describe('登录流程', () => {
beforeAll(async () => {
await device.launchApp({
newInstance: true,
permissions: { notifications: 'YES' },
});
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('首次启动应显示登录屏幕', async () => {
await expect(element(by.id('login-screen'))).toBeVisible();
await expect(element(by.id('email-input'))).toBeVisible();
await expect(element(by.id('password-input'))).toBeVisible();
});
it('应显示无效凭证的错误', async () => {
await element(by.id('email-input')).typeText('invalid@example.com');
await element(by.id('password-input')).typeText('wrongpassword');
await element(by.id('login-button')).tap();
await expect(element(by.id('error-message'))).toBeVisible();
await expect(element(by.text('无效凭证'))).toBeVisible();
});
it('成功登录后应导航到主页', async () => {
await element(by.id('email-input')).typeText('test@example.com');
await element(by.id('password-input')).typeText('password123');
await element(by.id('login-button')).tap();
await waitFor(element(by.id('home-screen')))
.toBeVisible()
.withTimeout(5000);
await expect(element(by.id('welcome-message'))).toBeVisible();
});
it('应处理长列表的滚动', async () => {
// 先登录
await element(by.id('email-input')).typeText('test@example.com');
await element(by.id('password-input')).typeText('password123');
await element(by.id('login-button')).tap();
await waitFor(element(by.id('home-screen'))).toBeVisible().withTimeout(5000);
// 滚动到列表底部
await element(by.id('item-list')).scrollTo('bottom');
await expect(element(by.id('item-50'))).toBeVisible();
// 滚动回顶部
await element(by.id('item-list')).scrollTo('top');
await expect(element(by.id('item-1'))).toBeVisible();
});
});
Maestro流程
# flows/login.yaml
appId: com.example.myapp
---
- launchApp:
clearState: true
- assertVisible: "Welcome"
- tapOn: "Email"
- inputText: "test@example.com"
- tapOn: "Password"
- inputText: "password123"
- tapOn: "Sign In"
- assertVisible: "Home"
- takeScreenshot: "home_after_login"
# 测试登出流程
- tapOn: "Profile"
- tapOn: "Sign Out"
- assertVisible: "Welcome"
XCUITest
// MyAppUITests/LoginTests.swift
import XCTest
final class LoginTests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["--uitesting"]
app.launch()
}
override func tearDownWithError() throws {
app = nil
}
func testLoginScreenElements() throws {
XCTAssertTrue(app.textFields["email-input"].exists)
XCTAssertTrue(app.secureTextFields["password-input"].exists)
XCTAssertTrue(app.buttons["login-button"].exists)
}
func testSuccessfulLogin() throws {
let emailField = app.textFields["email-input"]
let passwordField = app.secureTextFields["password-input"]
let loginButton = app.buttons["login-button"]
emailField.tap()
emailField.typeText("test@example.com")
passwordField.tap()
passwordField.typeText("password123")
loginButton.tap()
// 等待主页屏幕
let homeScreen = app.otherElements["home-screen"]
XCTAssertTrue(homeScreen.waitForExistence(timeout: 5))
}
func testInvalidCredentials() throws {
app.textFields["email-input"].tap()
app.textFields["email-input"].typeText("invalid@example.com")
app.secureTextFields["password-input"].tap()
app.secureTextFields["password-input"].typeText("wrong")
app.buttons["login-button"].tap()
XCTAssertTrue(app.staticTexts["Invalid credentials"].waitForExistence(timeout: 3))
}
func testScrollBehavior() throws {
// 导航到列表屏幕
app.buttons["list-tab"].tap()
let list = app.tables["item-list"]
let lastItem = app.cells["item-cell-50"]
// 向下滚动
while !lastItem.isHittable {
list.swipeUp()
}
XCTAssertTrue(lastItem.exists)
// 向上滚动
let firstItem = app.cells["item-cell-1"]
while !firstItem.isHittable {
list.swipeDown()
}
XCTAssertTrue(firstItem.exists)
}
}
Espresso测试
// app/src/androidTest/java/com/example/myapp/LoginTest.kt
package com.example.myapp
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class LoginTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeRule = createAndroidComposeRule<MainActivity>()
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun loginScreen_displaysAllElements() {
composeRule.onNodeWithTag("email-input").assertIsDisplayed()
composeRule.onNodeWithTag("password-input").assertIsDisplayed()
composeRule.onNodeWithTag("login-button").assertIsDisplayed()
}
@Test
fun login_withValidCredentials_navigatesToHome() {
composeRule.onNodeWithTag("email-input")
.performTextInput("test@example.com")
composeRule.onNodeWithTag("password-input")
.performTextInput("password123")
composeRule.onNodeWithTag("login-button")
.performClick()
composeRule.waitUntil(5000) {
composeRule.onAllNodesWithTag("home-screen")
.fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("home-screen").assertIsDisplayed()
}
@Test
fun login_withInvalidCredentials_showsError() {
composeRule.onNodeWithTag("email-input")
.performTextInput("invalid@example.com")
composeRule.onNodeWithTag("password-input")
.performTextInput("wrong")
composeRule.onNodeWithTag("login-button")
.performClick()
composeRule.onNodeWithText("Invalid credentials")
.assertIsDisplayed()
}
@Test
fun list_scrollsBehavior() {
// 先登录
composeRule.onNodeWithTag("email-input")
.performTextInput("test@example.com")
composeRule.onNodeWithTag("password-input")
.performTextInput("password123")
composeRule.onNodeWithTag("login-button")
.performClick()
composeRule.waitUntil(5000) {
composeRule.onAllNodesWithTag("item-list")
.fetchSemanticsNodes().isNotEmpty()
}
// 滚动到第50项
composeRule.onNodeWithTag("item-list")
.performScrollToNode(hasTestTag("item-50"))
composeRule.onNodeWithTag("item-50").assertIsDisplayed()
}
}
Firebase测试实验室
# .github/workflows/android-test.yml
name: Android测试
on: [push, pull_request]
jobs:
instrumented-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 设置JDK
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: 构建APK
run: |
./gradlew assembleDebug assembleDebugAndroidTest
- name: 认证Google Cloud
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_CREDENTIALS }}
- name: 在Firebase测试实验室上运行测试
run: |
gcloud firebase test android run \
--type instrumentation \
--app app/build/outputs/apk/debug/app-debug.apk \
--test app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
--device model=Pixel6,version=33 \
--device model=Pixel7,version=34 \
--timeout 15m \
--results-bucket gs://my-test-results
质量门
测试覆盖率
- 单元测试覆盖率> 80%
- 集成测试覆盖率> 60%
- E2E关键路径覆盖率100%
测试可靠性
- 易变测试率< 2%
- 测试执行时间< 15分钟
- 网络测试的重试逻辑
可访问性测试
- VoiceOver/TalkBack验证
- 颜色对比度验证
- 触摸目标大小验证
相关技能
accessibility-testing- 专注于可访问性测试mobile-perf- 性能测试fastlane-cicd- CI/CD集成
版本历史
- 1.0.0 - 初始发布,主要框架支持