基于 Maestro 的移动端 E2E 测试实战指南:YAML 声明式流程、选择器策略与 CI/CD 落地
【免费下载链接】opensource-ecommerce-mobile-app
This open-source mobile ecommerce app seamlessly transforms your Bagisto store into a powerful mobile platform, providing real-time synchronization of products and categories.
Maestro 是一套声明式 YAML 驱动的移动端 E2E 测试框架,以"自动等待、内置重试、零样板代码"著称,特别适合电商、社交等依赖复杂后端状态的应用。本文以仓库内的 maestro-mobile-testing 技能文档 为主体骨架,并结合本仓库(Bagisto 开源电商移动 App)中真实可运行的 maestro/ 测试工程展开讲解。读完本文,你将掌握:如何设计 testID/文本选择器、如何用认证预检与自适应流程消除 flaky、如何用 GraalJS 脚本抓取 OTP 验证码、如何编排 Mock API Server,以及如何把整套测试接入 Maestro Cloud 与 CI/CD。
Maestro 是什么:核心特性总览
Maestro 是移动端 E2E 测试框架,使用声明式 YAML 描述测试步骤,不需要编写命令式测试代码。其设计目标是让测试"写起来像在描述操作",并尽可能减少网络波动、动画时序带来的不确定性。
| 特性 | 说明 |
|---|---|
| 声明式 YAML | 无命令式测试代码,每个步骤即一条 YAML 指令 |
| 自动等待 | 不需要手写 sleep(),框架自动等待元素出现 |
| 内置重试 | retry: 块可对不稳定步骤自动重试,降低 flakiness |
| 快速执行 | 无需额外启动开销,测试运行迅速 |
| Maestro Studio | 交互式测试构建器,命令为 maestro studio |
| 子流程(Sub-flows) | 可复用的 YAML 片段,用于 DRY 测试 |
| JavaScript 脚本 | 基于 GraalJS 运行时,可发 HTTP 请求与处理数据 |
| Maestro Cloud | 在 CI 中使用真实设备执行测试,无需本地模拟器 |
从当前仓库的实践看,这套方法论已经完整落地:maestro/ 目录下包含 17 个 Android 端流程文件(覆盖登录、注册、忘记密码、购物车、目录、商品详情、结算、订单、全流程回归),以及一套复用的 helpers 子流程和全局 config.yaml 配置。
快速开始:安装、最小测试与运行
安装 Maestro CLI
curl -Ls "https://get.maestro.mobile.dev" | bash
brew install openjdk@17
export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
Maestro 依赖 Java 17 运行时,安装后需要将 JAVA_HOME 指向 JDK 17,否则运行测试时会报 "Unable to locate Java Runtime"(详见文末"常见错误"表)。
最小测试示例
appId: com.myapp
---
- launchApp
- tapOn:
id: "my-button"
- assertVisible: "Expected Text"
运行测试
maestro test .maestro/smoke-test.yaml
maestro test --debug .maestro/smoke-test.yaml # 逐步调试
maestro studio # 交互式构建器
在本仓库中,由于流程依赖大量环境变量,实际运行命令需要携带 --config 参数并指定设备,例如 config.yaml 顶部注释给出的用法:
maestro test maestro/flows/android/full_app_flow.yaml --config maestro/config.yaml --device emulator-5554
--config 会把 config.yaml 中的 env: 变量(如 APP_ID_ANDROID、USER_EMAIL、PRODUCT_NAME 等)预加载进所有流程,测试文件内部只需用 ${VAR} 引用即可。
核心模式(一):选择器策略 —— testID vs 文本
选择器是 E2E 测试的基石。Maestro 支持按 id:(即 testID / accessibility id)和按可见文本两种方式定位元素,两种都合法,选择哪种取决于项目上下文:
| 上下文 | 推荐选择器 | 理由 |
|---|---|---|
| 多语言 / i18n 应用 | id:(testID) | 跨翻译稳定 |
| 单语言应用 | 文本标签 | 可读性强、测试自文档化 |
| Agent 维护的测试 | 两者皆可,询问开发者 | AI 维护的流程对可读性要求较低 |
| 系统对话框 | 始终用文本 | 原生弹窗上没有 testID 可用 |
# testID 选择器 —— 跨翻译稳定
- tapOn:
id: "submit-button"
# 文本选择器 —— 可读、自文档化
- tapOn: "Submit"
何时优先用 testID: 应用支持多语言或将要翻译、UI 文案动态变化频繁、多个元素共享相同可见文本。
何时优先用文本: 单语言应用且文案稳定、可读性与自文档化优先、需要严格按用户可见行为测试。
在 React Native 中,使用 ID 选择器时需要给组件挂 testID 属性:
<TouchableOpacity testID="submit-button" onPress={handleSubmit}>
<Text>{t('submit')}</Text>
</TouchableOpacity>
说明:SKILL.md 面向 React Native / Expo 应用举例;本仓库实际是 Flutter(Bagisto)电商 App,其
maestro/测试工程采用的是"文本标签优先"策略,详见下文"仓库落地实践"。
testID 命名规范
当使用 ID 选择器时,遵循统一命名模式,让选择器可预测、可维护:
{component}-{action/type}[-{variant}]
示例:
- auth-prompt-login-button
- product-card-{id}
- otp-input-0
- tab-home
- dashboard-loading
核心模式(二):认证预检(Auth Pre-Flight Pattern)
E2E 最常见的 flaky 来源,是 Maestro 在认证状态尚未解析完成时就与 UI 交互。解决办法是在根布局中放置一个零尺寸的 auth-loaded 标记,仅在认证加载完成后渲染:
// 在 tab bar 或根布局中
{!isLoading && <View testID="auth-loaded" style={{ width: 0, height: 0 }} />}
随后每个测试都先等它出现:
- launchApp
# 防止 iOS 冷启动 XCTest 崩溃
- swipe:
direction: DOWN
duration: 100
# 等待认证状态解析完成
- extendedWaitUntil:
visible:
id: "auth-loaded"
timeout: 15000
# 现在可以安全交互
- tapOn:
id: "tab-home"
核心模式(三):自适应测试(Adaptive Tests)
测试不应假设用户处于登录或游客态,而应通过 when: 条件动态分支:
# 认证流程 —— 仅当登录提示可见时执行
- runFlow:
when:
visible: "Sign In"
file: flows/auth-flow.yaml
# 已登录 —— 直接继续
- runFlow:
when:
visible:
id: "tab-home"
file: flows/authenticated-action.yaml
这一模式在本仓库的 helpers 中被大量复用。例如 auth_logout.yaml 中,先尝试进入账户页,若发现处于未登录态(可见 Login),就动态调用登录子流程再继续登出断言:
- runFlow:
when:
visible:
text: "^Login$"
commands:
- runFlow: helpers/open_login_from_account.yaml
- runFlow: helpers/login_existing_customer.yaml
- assertVisible:
text: "^My Orders$"
核心模式(四):乐观更新(Optimistic Updates)验证
很多现代 App(尤其是社交、电商类)采用乐观 UI:点击后界面立即变化,不等服务器响应。测试这类行为要用短超时验证"UI 在服务器响应前就变了":
# 触发变更
- tapOn:
id: "action-button"
# 乐观更新:UI 必须在 3s 内变化(不是等服务器)
- extendedWaitUntil:
visible:
id: "undo-button"
timeout: 3000
# 验证派生出的 UI 状态
- extendedWaitUntil:
visible:
id: "user-indicator"
timeout: 5000
| 动作 | 预期变化 | 超时 |
|---|---|---|
| 触发变更 | 按钮状态翻转 | < 3s |
| 列表更新 | 条目出现/消失 | < 5s |
| 重做动作 | 证明持久化生效 | < 3s |
核心模式(五):原生弹窗处理
RN 的 Alert.alert() 会创建阻塞 UI 的原生对话框。处理套路是:先等待预期状态变化,再用 optional: true 的文本点击关闭弹窗:
- tapOn:
id: "action-button"
# 先等待预期状态变化
- extendedWaitUntil:
visible:
id: "new-state-element"
timeout: 5000
# 关闭弹窗(optional 以防它已自动关闭)
- tapOn:
text: "OK"
optional: true
# 短暂等待弹窗动画结束
- swipe:
direction: DOWN
duration: 300
核心模式(六):子流程复用(Sub-Flows)
把重复序列抽成子流程文件,主流程通过 runFlow: 引用:
.maestro/
├── flows/
│ ├── auth-and-return.yaml
│ ├── complete-purchase.yaml
│ └── verify-result.yaml
├── smoke-test.yaml
└── feature-test.yaml
# 主测试中
- runFlow:
file: flows/auth-and-return.yaml
本仓库对此的实践非常典型:maestro/flows/android/helpers/ 下集中了 normalize_to_home_attached.yaml、ensure_guest_account_screen.yaml、open_login_from_account.yaml、login_existing_customer.yaml、dismiss_runtime_permissions.yaml 五个可复用子流程,被 auth_login_valid.yaml、auth_logout.yaml、checkout_*、full_app_flow_attached.yaml 等十余个主流程共享,真正做到"一次编写、处处复用"。
核心模式(七):深链接(Deep Links)
使用 app.json 中的 Expo scheme,而不是 bundle ID:
# 错误
- openLink: "com.myapp://profile/settings"
# 正确
- openLink: "myapp://profile/settings"
深链接必须在应用的深链处理器中注册,未注册的路由会静默失败。
核心模式(八):平台特定逻辑
- runFlow:
when:
platform: ios
file: flows/ios-specific.yaml
- runFlow:
when:
platform: android
file: flows/android-specific.yaml
核心模式(九):环境变量
appId: com.myapp
env:
TEST_EMAIL: maestro-test@example.com
API_BASE_URL: http://localhost:3000
---
- inputText: ${TEST_EMAIL}
本仓库把这一模式做到了极致:所有账号、商品、地址、文案选择器全部收口到 config.yaml 与 keys.yaml 的 env 中,例如:
env:
# App IDs
APP_ID_ANDROID: "com.webkul.bagisto.mobikul"
APP_ID_IOS: "com.webkul.bagistoApp.iOS"
# Credentials
USER_EMAIL: "demo.user@example.com"
USER_PASSWORD: "ExamplePass123!"
FORGOT_PASSWORD_EMAIL: "demo.user@example.com"
# Product under test
PRODUCT_NAME: "Apple iPhone Smartphone"
CONFIG_SIZE_OPTION: "Medium"
CONFIG_COLOR_OPTION: "Blue"
# Validation messages
INVALID_LOGIN_ERROR: "Invalid credentials"
EXISTING_USER_ERROR: "Email already exists"
# Checkout data
ADDRESS_FULL_NAME: "QA User"
SHIPPING_METHOD_LABEL: "Flat Rate"
PAYMENT_METHOD_LABEL: "Cash On Delivery"
流程文件内用 ${PRODUCT_NAME}、${USER_EMAIL} 等引用。注意 keys.yaml 与 config.yaml 存在字段差异(如 remove_item_button 一处为 "Delete" 一处为 "Remove"),说明这是两套历史配置,使用时以你实际执行的那份为准。
核心模式(十):选择器状态属性(State Properties)
用 enabled、selected、checked、focused 等状态属性定位元素,非常适合在动作前后校验交互元素的可用性:
# 仅当提交按钮可用时才点击
- tapOn:
id: "submit-button"
enabled: true
# 断言复选框已被勾选
- assertVisible:
id: "terms-checkbox"
checked: true
# 等待输入框获得焦点
- extendedWaitUntil:
visible:
id: "email-input"
focused: true
timeout: 3000
| 属性 | 取值 | 使用场景 |
|---|---|---|
enabled | true / false | 提交中禁用、表单未合法时禁用的按钮 |
checked | true / false | 复选框、开关 |
selected | true / false | Tab 项、分段控件 |
focused | true / false | 自动聚焦的输入框 |
核心模式(十一):相对位置选择器(Relative Position)
当多个相似元素并存时,用空间关系区分比用 index 更健壮、更自文档化:
# 差 —— 脆弱,顺序一变就挂
- tapOn:
text: "Add to Basket"
index: 1
# 好 —— 上下文明确、自文档化
- tapOn:
text: "Add to Basket"
below:
text: "Awesome Shoes"
可选用的相对选择器完整清单:
# 目标元素在另一元素下方
- tapOn:
text: "Buy Now"
below: "Product Title"
# 目标元素是某父元素的子元素
- tapOn:
text: "Delete"
childOf:
id: "item-card-42"
# 目标父元素包含某个直接子元素
- tapOn:
containsChild: "Urgent"
# 目标元素包含多个后代
- tapOn:
containsDescendants:
- id: title_id
text: "Specific Title"
- "Another descendant text"
# 水平定位
- tapOn:
text: "Edit"
rightOf: "Username"
| 选择器 | 含义 |
|---|---|
below: | 元素位于参照元素下方 |
above: | 元素位于参照元素上方 |
leftOf: | 元素位于参照元素左侧 |
rightOf: | 元素位于参照元素右侧 |
childOf: | 元素是参照父元素的直接子元素 |
containsChild: | 元素包含匹配参照的直接子元素 |
containsDescendants: | 元素包含全部指定的后代元素 |
本仓库的 helpers 中大量使用相对定位。例如 open_login_from_account.yaml 用 rightOf: "^Sign Up$" 精确定位登录按钮(同一屏存在 Sign Up 与 Login 两个按钮):
- tapOn:
text: "^Login$"
rightOf: "^Sign Up$"
retryTapIfNoChange: true
waitToSettleTimeoutMs: 1500
认证测试:OTP 与 Magic Link 的 E2E 架构
测试 OTP 或 magic-link 登录,核心挑战是"在 E2E 中程序化地捕获邮件"。通用架构如下:
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Maestro │────▶│ Auth │────▶│ Email Capture │
│ Test │ │ Provider │ │ Service │
└─────────────┘ └──────────────┘ └─────────────────┘
│ │
│ ┌──────────────────────────────┘
│ ▼
│ ┌─────────────┐
└──▶│ REST API │ ─── GET /api/v1/messages
│ (email) │ ─── Extract OTP code
└─────────────┘
常见的邮件捕获服务有 Mailpit、MailHog、Ethereal。流程是:测试触发认证提供方发送邮件 → 邮件捕获服务通过 REST API 暴露邮件 → Maestro 脚本轮询 API 提取 OTP 码。
OTP 抓取脚本(GraalJS)
// 关键:Maestro 使用 GraalJS —— 不支持 async/await、不支持 fetch()
var email = typeof EMAIL !== "undefined" ? EMAIL : "test@example.com";
var emailServiceUrl = typeof EMAIL_SERVICE_URL !== "undefined"
? EMAIL_SERVICE_URL : "http://localhost:8025";
var response = http.get(emailServiceUrl + "/api/v1/messages");
if (!response.ok) {
throw new Error("Failed to fetch emails: " + response.status);
}
var data = json(response.body);
// 取最新一封邮件并提取 OTP 码
var body = data.messages[0].Content.Body;
var match = body.match(/(\d{6})/);
output.OTP_CODE = match[1];
OTP 输入策略
自动聚焦的 OTP 组件需要逐位输入,每位先 tap 再输入:
# 通过辅助脚本拆分 OTP 为单数字
- runScript:
file: scripts/split-otp.js
env:
OTP_CODE: ${output.OTP_CODE}
# 逐位 tap 输入框再输入
- tapOn:
id: "otp-input-0"
- inputText: ${output.OTP_0}
- tapOn:
id: "otp-input-1"
- inputText: ${output.OTP_1}
# ... 对每位数字重复
对于具体提供方(Supabase + Mailpit、Firebase Auth、Auth0),建议在项目级 skill 中扩展本文。
GraalJS 脚本规则
Maestro 的脚本运行在 GraalJS 之上,以下约束是硬性的:
| 特性 | 状态 |
|---|---|
async/await | 不支持 |
fetch() | 不支持 |
http.get()、http.post() | 用它们替代 fetch |
json() | 用于解析响应体 |
output.VAR | 向 YAML 流程暴露变量 |
var 声明 | 必须使用(用 var 而非 const/let 更安全) |
// 脚本模板
var response = http.get("http://localhost:8025/api/endpoint");
if (!response.ok) {
throw new Error("Request failed: " + response.status);
}
var data = json(response.body);
output.RESULT = data.value;
本仓库提供了一个极简但完整的 GraalJS 脚本范例 generate_signup_email.js,用于注册测试生成唯一邮箱,避免重复注册冲突:
var prefix = typeof EMAIL_PREFIX !== "undefined" ? EMAIL_PREFIX : "qa.signup";
var timestamp = String(Date.now());
output.UNIQUE_SIGNUP_EMAIL = prefix + "+" + timestamp + "@example.com";
它在 auth_signup.yaml 中通过 runScript 调用,并把结果注入注册表单:
- runScript:
file: ../../scripts/generate_signup_email.js
env:
EMAIL_PREFIX: ${SIGNUP_EMAIL_PREFIX}
...
- tapOn:
text: "^Enter your email$"
- inputText: ${output.UNIQUE_SIGNUP_EMAIL}
注意脚本通过 typeof EMAIL_PREFIX !== "undefined" 做参数兜底,这是 GraalJS 脚本处理可选环境变量的标准写法。脚本执行后,流程用 extendedWaitUntil 等待 "Login to your account|My Orders" 二选一出现,并针对两种注册结果分别断言,体现了"自适应"思想。
关键坑(Critical Gotchas)
clearState 不会清除 iOS Keychain
clearState: true 只清除 App 沙盒(UserDefaults、文件、缓存),不会清除 iOS Keychain。通过 expo-secure-store(或任何基于 Keychain 的存储)保存的认证令牌,在 clearState 重置、甚至应用重装后依然存在。
# 错误 —— 用户可能仍处于登录态
- launchApp:
clearState: true
- assertVisible: "Welcome" # 若 Keychain 中有令牌则失败
# 正确 —— 等待认证解析,然后自适应
- launchApp
- extendedWaitUntil:
visible:
id: "auth-loaded"
timeout: 15000
规则:
- 永远不要依赖
clearState在 iOS 上制造游客态 - 认证测试:跳过
clearState,用auth-loaded预检 - 游客测试:用同时兼容两种状态的自适应流程
- 永远不要在
clearState后断言"仅游客可见"的 UI
注意: Android 上 clearState: true 会完整重置应用数据(包括凭据),这是 iOS 专属的坑。
XCTest kAXErrorInvalidUIElement 崩溃(iOS)
冷启动时,如果 Maestro 在首个渲染周期完成前就访问无障碍树,XCTest 驱动可能崩溃。
修复: 在 launchApp 后紧跟一个无操作 swipe:
- launchApp
- swipe:
direction: DOWN
duration: 100
API 服务器依赖
调用后端 API 的 App 在 localhost 上运行时,需要完整服务器或 mock 服务器在运行;否则所有依赖 API 的屏幕都会显示加载中或空状态(查询静默失败)。
修复: 运行测试前先启动 mock API 服务器:
# 启动 mock 服务器(在你的 API 端口上返回预设响应)
npx tsx scripts/mock-api-server.ts &
# 再运行测试
maestro test .maestro/my-test.yaml
为 App 调用的每个端点返回预设 JSON 的轻量 mock,比跑完整后端更快、更确定。
认证感知的 Tab 栏
游客与登录用户显示不同 Tab 的 Tab 栏会导致选择器失败:
| 状态 | 典型 Tabs |
|---|---|
| 游客 | home, search, cart, profile |
| 已登录 | home, feed, create, messages, profile |
只断言两种状态下都存在 Tab,或使用自适应 when: 条件。
测试文件模板
# {Feature} {Action} Test
#
# Tests: {这个测试验证什么}
# Prerequisites:
# - 模拟器/真机已运行且 App 已安装
# - 后端或 mock 服务器已运行(若依赖 API)
appId: com.myapp
env:
TEST_EMAIL: maestro-{feature}@example.com
EMAIL_SERVICE_URL: http://localhost:8025
---
# ==========================================
# STEP 1: LAUNCH + AUTH PRE-FLIGHT
# ==========================================
- launchApp
- swipe:
direction: DOWN
duration: 100
- extendedWaitUntil:
visible:
id: "auth-loaded"
timeout: 15000
- takeScreenshot: 01-initial-state
# ==========================================
# STEP 2: {ACTION}
# ==========================================
- tapOn:
id: "target-element"
# ==========================================
# STEP 3: VERIFY
# ==========================================
- extendedWaitUntil:
visible:
id: "expected-result"
timeout: 5000
- takeScreenshot: 02-final-state
目录结构与命名约定
推荐的测试工程目录结构:
.maestro/
├── README.md # 速查手册 + testID 清单
├── config.yaml # 共享配置
├── flows/ # 可复用子流程
│ ├── auth-and-return.yaml
│ ├── complete-action.yaml
│ └── verify-result.yaml
├── scripts/ # GraalJS 辅助脚本
│ ├── fetch-otp.js
│ └── split-otp.js
├── smoke-test.yaml # 游客导航
├── auth-signin.yaml # OTP 登录流程
├── feature-screenshots.yaml # 截图流程
└── feature-action.yaml # 功能专属测试
scripts/
├── mock-api-server.ts # 供 E2E 使用的轻量 mock
└── run-e2e.sh # 编排脚本
命名约定
| 类型 | 模式 | 示例 |
|---|---|---|
| 主测试 | {feature}-{action}.yaml | checkout-purchase.yaml |
| 子流程 | {action}-{context}.yaml | auth-and-return-to-dashboard.yaml |
| 脚本 | {verb}-{noun}.js | fetch-otp.js |
仓库的目录布局与此高度一致:maestro/flows/android/ 放主流程(如 auth_login_valid.yaml、cart_flow.yaml、checkout_guest.yaml),maestro/flows/android/helpers/ 放可复用子流程,maestro/scripts/ 放 GraalJS 脚本,config.yaml 承担共享配置,并额外提供了 LABEL_BASED_INSTRUCTIONS.md 作为选择器策略的工程约束文档。
基础设施:Mock 服务器、编排脚本与种子数据
Mock 服务器
为 API 层返回预设响应的轻量 mock,比 E2E 期间跑完整后端更快更确定:
# 运行 Maestro 测试前先启动 mock 服务器
npx tsx scripts/mock-api-server.ts &
编排脚本
用 shell 脚本自动化完整 E2E 准备流程:
- 启动后端服务(数据库、认证)
- 注入种子测试数据
- 启动 mock API 服务器
- 运行 Maestro 测试
- 清理所有服务
bash scripts/run-e2e.sh
种子数据
依赖特定数据的测试需要种子数据库。把种子脚本放在测试基础设施旁,在每套测试运行前执行。
Android 专属模式
模拟器搭建
Android 测试需要模拟器或 USB 连接的物理设备,Maestro 自动检测已连接设备:
# 列出可用的系统镜像
sdkmanager --list | grep system-images
# 创建模拟器
avdmanager create avd -n maestro_test \
-k "system-images;android-34;google_apis;arm64-v8a"
# 启动模拟器
emulator -avd maestro_test
iOS 与 Android 差异
| 方面 | iOS | Android |
|---|---|---|
| 设备类型 | 仅模拟器(不支持物理设备) | 模拟器 + 通过 ADB 的物理设备 |
clearState | 不清除 Keychain | 完整重置应用数据 |
| 冷启动崩溃 | XCTest kAXError(需加 swipe 延时) | 无对应问题 |
| 性能 | 原生运行(快) | ARM 模拟(x86 上较慢) |
| 权限弹窗 | 系统警告 | 文本不同的系统对话框 |
ADB 调试
adb devices # 列出已连接设备
adb shell am start -n com.myapp/.MainActivity # 启动应用
adb logcat | grep Maestro # 过滤 Maestro 日志
adb shell input keyevent 82 # 解锁屏幕
Android 权限处理
Android 权限以系统对话框形式出现,用 optional tap 关闭:
- tapOn:
text: "Allow"
optional: true
- tapOn:
text: "While using the app"
optional: true
仓库对此封装了专门的 helpers 子流程 dismiss_runtime_permissions.yaml,用 when: visible 条件一次性尝试关闭所有常见权限文案变体:
- runFlow:
when:
visible:
text: "^(Allow only while using the app|While using the app|Only this time|Allow)$"
commands:
- tapOn:
text: "^Allow only while using the app$"
optional: true
waitToSettleTimeoutMs: 750
- tapOn:
text: "^While using the app$"
optional: true
waitToSettleTimeoutMs: 750
- tapOn:
text: "^Only this time$"
optional: true
waitToSettleTimeoutMs: 750
- tapOn:
text: "^Allow$"
optional: true
waitToSettleTimeoutMs: 750
因为该子流程内层 tap 全部是 optional: true,无论当前弹出哪种权限文案都能被关闭,且不会因元素不存在而失败,可被安全地嵌入任意流程任意位置。
仓库落地实践:Flutter 电商 App 的 Maestro 测试工程
前面是 SKILL.md 的通用方法论,接下来看本仓库如何把它真正落地(结合 maestro 目录下可运行的真实工程)。
策略约束:标签优先,禁用坐标
LABEL_BASED_INSTRUCTIONS.md 明确规定了本项目(含附加流程 full_app_flow_attached.yaml)的选择器纪律:
- 使用基于标签(label-based)的选择器
- 不要使用
point:坐标选择器 - 优先用可见文本与无障碍标签进行导航和操作
- 保持流程步骤可读,便于 UI 变化时维护
这是一条工程级约束:相比坐标点选,文本标签在屏幕尺寸、分辨率变化下更稳定。实际各流程也大量使用锚定正则(如 ^Login$、^My Orders$)来精确匹配、避免子串误命中。
会话恢复:normalize_to_home_attached
E2E 经常面临"上次运行中断,App 停在未知页面"的问题。normalize_to_home_attached.yaml 是仓库给出的解法:通过 retry: maxRetries: 3 包裹一组 runFlow: when: visible 条件,逐一识别 "Checkout"、"Orders #.*"、"Search products"、"Add to Cart|Buy Now"、"Create Account"、"Forgot Password?" 等中间页面并 back 返回,最终断言底部导航的 Home 与 Account 可见,把 App 归一化到可继续执行的根屏幕。这种"先恢复、再执行"的思路,与 SKILL.md 的 auth-loaded 预检哲学一脉相承,只是把"等待状态"升级为"主动归位"。
完整回归:full_app_flow_attached
full_app_flow_attached.yaml 是该工程的压轴流程:归一化 → 确保登录(自适应补登录)→ 搜索商品 → 加入购物车 → Buy Now → 结算(含支付方式选择)→ 下单成功 → 查看订单详情(Price Break / Grand Total)。全程依赖 retry、scrollUntilVisible、extendedWaitUntil 与 dismiss_runtime_permissions 的组合,可视为 SKILL.md 全部核心模式在电商场景下的完整示范。
CI/CD 集成
GitHub Actions + Maestro Cloud
Maestro Cloud 在 CI 中提供真实设备,无需本地模拟器。使用官方 action:
name: Mobile E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
maestro-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# 构建 Android APK
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Build Android APK
run: |
cd apps/mobile
npx expo prebuild --platform android --no-install
cd android && ./gradlew assembleRelease
- name: Run Maestro Cloud Tests
id: maestro
uses: mobile-dev-inc/action-maestro-cloud@v2
with:
api-key: ${{ secrets.MAESTRO_API_KEY }}
app-file: apps/mobile/android/app/build/outputs/apk/release/app-release.apk
workspace: .maestro
include-tags: ci
# 访问结果
# ${{ steps.maestro.outputs.MAESTRO_CLOUD_CONSOLE_URL }}
# ${{ steps.maestro.outputs.MAESTRO_CLOUD_UPLOAD_STATUS }}
# ${{ steps.maestro.outputs.MAESTRO_CLOUD_FLOW_RESULTS }}
基于 Tag 的流程过滤
用 tags 控制哪些测试进 CI、哪些只在本地跑:
# 流程文件头部
appId: com.myapp
tags:
- ci
- smoke
---
- launchApp
# ... 测试步骤
# 本地只跑带 ci 标签的流程
maestro test --include-tags ci .maestro/
# 排除进行中的流程
maestro test --exclude-tags wip .maestro/
仓库流程也遵循了这套规范,例如 auth_login_valid.yaml 的 tags 为 auth / smoke / android,full_app_flow.yaml 为 e2e / regression / android,便于按 tag 精细调度。
本地 Docker CI(仅 Android)
FROM openjdk:17-slim
RUN curl -Ls "https://get.maestro.mobile.dev" | bash
ENV PATH="/root/.maestro/bin:${PATH}"
COPY .maestro/ /app/.maestro/
WORKDIR /app
CMD ["maestro", "test", ".maestro/"]
注意: iOS 测试无法在 Docker 中运行(需要 macOS),CI 中的 iOS 请使用 Maestro Cloud。
Maestro Cloud
Maestro Cloud 在真实设备上运行测试,无需本地模拟器搭建。
设置步骤
- 在 cloud.maestro.dev 创建账号
- 从控制台生成 API key
- 在 CI 提供商中把 key 存为
MAESTRO_API_KEY密钥
从 CLI 运行
# 上传并在 Maestro Cloud 上运行
maestro cloud --api-key $MAESTRO_API_KEY \
--app-file ./app-release.apk \
.maestro/
# 带 tag 过滤
maestro cloud --api-key $MAESTRO_API_KEY \
--app-file ./app-release.apk \
--include-tags smoke \
.maestro/
要点
- iOS 测试:Maestro Cloud 支持(本地物理设备不支持)
- Android 测试:本地物理设备与 Maestro Cloud 均支持
- 结果:仪表盘提供视频录制、日志与截图
- CI 输出:
MAESTRO_CLOUD_CONSOLE_URL、MAESTRO_CLOUD_FLOW_RESULTS
Maestro MCP 服务器:让 Agent 直接执行测试
Maestro MCP 服务器把 Maestro 的完整命令集暴露为 Model Context Protocol 工具,让 AI Agent 直接执行测试、与设备交互,而不仅仅是编写 YAML。
与本文技能的分工
| 本文技能 | Maestro MCP | |
|---|---|---|
| 角色 | 教正确的模式 | 提供运行时执行 |
| 层次 | 编写(写出好 YAML) | 执行(运行、点击、断言、截图) |
| 产出 | 更好的测试文件 | 实时设备交互 |
两者配合使用:技能保证 AI 写出正确的测试;MCP 让它能立即运行、看到失败、迭代修复。
安装
MCP 随 Maestro CLI 内置,无需额外安装:
# 验证可用
maestro mcp
Claude Code —— 加入项目的 .mcp.json 或全局设置:
{
"mcpServers": {
"maestro": {
"command": "maestro",
"args": ["mcp"]
}
}
}
Claude Desktop —— 加入 ~/Library/Application Support/Claude/claude_desktop_config.json(macOS):
{
"mcpServers": {
"maestro": {
"command": "maestro",
"args": ["mcp"]
}
}
}
同样支持 Cursor、Windsurf、VS Code 与 JetBrains 系列 IDE。
核心能力
MCP 服务器按类别暴露 47 个工具:
| 类别 | 工具 | 示例 |
|---|---|---|
| UI 交互 | tap、swipe、scroll、长按 | tapOn、scrollUntilVisible |
| 文本输入 | type、erase、paste、copy | inputText、eraseText |
| 断言 | 可见性、AI 驱动 | assertVisible、assertWithAI |
| 应用生命周期 | launch、stop、清状态 | launchApp、clearState |
| 设备控制 | 定位、方向、飞行模式 | setLocation、hideKeyboard |
| 流程控制 | 跑流程、重复、执行脚本 | runFlow、evalScript |
| 媒体 | 截图、录制 | takeScreenshot、startRecording |
| AI 增强 | 视觉断言、缺陷检测 | assertWithAI、assertNoDefectsWithAI |
写-跑-修循环
技能与 MCP 同时启用后,AI 可以:
- 写:按本文模式编写测试 YAML
- 跑:通过 MCP 的
runFlow/launchApp及交互工具执行 - 看:通过截图与断言观察失败
- 修:修改 YAML 重跑 —— 全部在同一个会话内完成
调试技巧
maestro test --debug .maestro/test.yaml # 交互式逐步调试
maestro record .maestro/test.yaml # 录制成视频
maestro studio # 交互式 UI 构建器
maestro hierarchy # 查看元素树
截图保存在 ~/.maestro/tests/{timestamp}/。
新测试编写清单
[ ] 使用唯一测试邮箱(maestro-{feature}@example.com)
[ ] 选择器策略已确定(i18n 应用用 testID,单语言用文本 —— 见模式一)
[ ] 相关处使用状态属性选择器(enabled、checked —— 见模式十)
[ ] 相似元素用相对选择器区分,而非 index(见模式十一)
[ ] 使用认证预检模式(auth-loaded)
[ ] 启动后添加 swipe(iOS 崩溃预防)
[ ] 处理两种认证状态(自适应流程)
[ ] 变更后关闭原生弹窗
[ ] 乐观更新使用短超时(3-5s)
[ ] 可复用序列抽成子流程
[ ] 关键节点保存描述性截图
[ ] 头部注释写明前置条件
[ ] 添加到 README.md 测试表
[ ] 依赖后端时启动 mock API 服务器
[ ] 添加 CI 过滤 tags(ci、smoke、wip)
常见错误速查表
| 错误 | 原因 | 修复 |
|---|---|---|
| "Unable to locate Java Runtime" | Java 不在 PATH | export JAVA_HOME=/opt/homebrew/opt/openjdk@17/... |
| 点击后 "Element not found" | 原生弹窗阻塞 | 添加 tapOn: text: "OK" optional: true |
| OTP 数字输不进去 | 自动聚焦干扰 | 用独立的 otp-input-N testID |
| 测试通过但什么都没发生 | 滥用 optional: true | 仅对真正可选的动作用 optional |
| 可见性断言为 false | 元素尚未渲染 | 加大超时或确认 testID 存在 |
| 脚本输出为空 | JS API 用错 | 用 http.get() 而非 fetch() |
| clearState 后认证状态不一致 | iOS Keychain 未清除 | 不用 clearState,改用自适应流程 |
| kAXErrorInvalidUIElement 崩溃 | iOS 冷启动竞态 | 启动后加 swipe 延时 |
| 加载中/空屏 | 未运行 API 服务器 | 测试前启动 mock API 服务器 |
| 权限弹窗阻塞(Android) | 系统对话框未关闭 | 添加 tapOn: text: "Allow" optional: true |
小结
Maestro 的声明式 YAML、自动等待、内置重试与 GraalJS 脚本能力,为移动端 E2E 提供了一条低样板、高稳定的路径;而选择器策略、认证预检、自适应流程、乐观更新验证、Mock API 与 CI/CD 编排这些模式,则是让这套路径真正可落地、可维护的关键。本仓库的 maestro/ 工程(config.yaml、helpers 子流程、全流程回归、GraalJS 脚本)即为上述全部方法论的真实范本,可作为你搭建自身移动端 E2E 测试体系的直接参考。
【免费下载链接】opensource-ecommerce-mobile-app
This open-source mobile ecommerce app seamlessly transforms your Bagisto store into a powerful mobile platform, providing real-time synchronization of products and categories.
更多推荐

所有评论(0)