关于 mobilewright
mobilewright 是面向 iOS 和 Android 的 Playwright 风格框架与 Skill,用于应用测试、设备自动化和 UI 验证。它支持 TypeScript 与 JavaScript,并提供视图树检查和定位器交互。
主要功能
- 01iOS 与 Android 设备自动化
- 02端到端、回归及冒烟测试
- 03检查视图树和屏幕截图
- 04基于定位器的 UI 交互
- 05测试 Fixture 与自动重试断言
- 06用于配置、设备和测试的 CLI
适用场景
- 01运行移动应用回归测试
- 02自动执行重复的设备操作
- 03在设备上检查新实现的 UI
- 04采集屏幕截图和无障碍视图树
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ mobile-next/ mobilewright-skill --skill mobilewright安装后在 Claude Code 中输入/mobilewright确认已加载。
详细介绍
Mobilewright Skill
使用 mobilewright 自动化移动设备、进行测试和开发验证——这是一个受 Playwright 启发、适用于 iOS 和 Android 的框架。
在以下情况下使用此技能:
- 编写移动应用测试(端到端、回归、冒烟测试)
- 自动执行移动设备交互(抓取、截图、重复性任务)
- 在移动应用开发期间验证 UI 实现——截取屏幕截图、导出视图树,并确认你编写的代码产生了预期结果
第一步
编写任何代码之前,先收集相关信息:
- 你要做什么? 询问用户:测试、自动化脚本,还是验证刚构建的 UI?
- 使用什么平台? 询问用户:iOS、Android,还是两者都用?
- 使用 TypeScript 还是 JavaScript? 询问用户。两者均受支持。
- 自动查找 bundle ID。 不要询问用户——自行查找:
- iOS:在
*.pbxproj文件中搜索PRODUCT_BUNDLE_IDENTIFIER,或在 Expo 的app.json/app.config.js中搜索bundleIdentifier - Android:在
build.gradle或build.gradle.kts中搜索applicationId,或在AndroidManifest.xml中搜索package - React Native / Expo:检查
app.json中的expo.ios.bundleIdentifier和expo.android.package - 如果都没找到,检查是否已有设置了
bundleId的mobilewright.config.ts - 只有在无法自动找到时,才询问用户
- iOS:在
- 是否已有
mobilewright.config.ts? 如果没有,请创建一个。
项目设置
搭建新项目:
npx mobilewright init
这会创建 mobilewright.config.ts 和 example.test.ts。验证环境:
npx mobilewright doctor
CLI 命令
npx mobilewright init # scaffold config + example test
npx mobilewright doctor # verify environment setup
npx mobilewright devices # list connected devices/simulators
npx mobilewright test # run tests
npx mobilewright screenshot # take a screenshot of a connected device
npx mobilewright screenshot -o login.png # save to specific file
npx mobilewright screenshot -d <device-id> # target a specific device
配置
import { defineConfig } from 'mobilewright';
export default defineConfig({
platform: 'ios', // 'ios' or 'android'
bundleId: 'com.example.myapp', // app bundle ID
deviceName: 'iPhone 16', // regex to match device name (optional)
timeout: 10_000, // global timeout in ms (optional)
});
UI 检查——先执行此步骤
编写任何定位器之前,务必先检查 UI 树。 不要猜测元素名称、标签或类型。使用 screen.viewTree() 获取实时无障碍层级结构,然后根据所见选择最佳定位策略。
const tree = await screen.viewTree();
console.log(JSON.stringify(tree, null, 2));
这会以 JSON 格式返回完整的视图层级结构。检查其中的以下信息:
- 无障碍标识符(
testId) - 无障碍标签(
label) - 元素类型(
type) - 文本内容(
text/value) - 占位文本
然后按以下优先级选择定位器:
定位器优先级(从优到次)
| 优先级 | 定位器 | 原因 |
|---|---|---|
| 1 | getByTestId('login-btn') | 最稳定。由开发者设置,不会随文案或国际化而改变。 |
| 2 | getByRole('button', { name: 'Sign In' }) | 语义明确且跨平台。在 iOS 和 Android 上都映射到原生类型。 |
| 3 | getByLabel('Sign In') | 无障碍标签。只要持续维护无障碍信息,就很稳定。 |
| 4 | getByPlaceholder('Search...') | 适用于文本字段。仅 Locator 支持,Screen 不支持。 |
| 5 | getByText('Sign In') | 不稳定。文案更新、国际化或轻微措辞调整都会导致变化。 |
| 6 | getByType('Button') | 特定于平台。会使代码依赖 iOS 或 Android 的元素类型。 |
测试文件结构
使用 @mobilewright/test,它通过移动设备 fixture 扩展了 Playwright Test。
import { test, expect } from '@mobilewright/test';
test.use({ bundleId: 'com.example.myapp', video: 'on' });
test.beforeEach(async ({ device, bundleId }) => {
// Fresh app state for every test
await device.terminateApp(bundleId).catch(() => {});
await device.launchApp(bundleId);
});
test.describe('login flow', () => {
test('user can sign in with valid credentials', async ({ screen }) => {
await screen.getByLabel('Email').fill('[email protected]');
await screen.getByLabel('Password').fill('password123');
await screen.getByRole('button', { name: 'Sign In' }).tap();
await expect(screen.getByText('Welcome back')).toBeVisible();
});
});
运行测试:
npx mobilewright test
npx mobilewright test login.test.ts # specific file
npx mobilewright test --grep "sign in" # run a single test by name
npx mobilewright test --reporter list # line-by-line output, good for CI/agents
npx mobilewright test --reporter json # machine-readable JSON output
可用 Fixture
| Fixture | 范围 | 描述 |
|---|---|---|
screen | 测试 | 设备屏幕——用于所有元素交互 |
device | worker | 设备连接——用于应用生命周期、方向和 URL |
bundleId | 测试 | 配置的 bundle ID 字符串 |
通过 test.use() 设置测试选项
test.use({
bundleId: 'com.example.app', // override app bundle ID
platform: 'android', // override platform
deviceId: '5A5FCFCA-...', // target specific device
video: 'on', // record video ('on' | 'retain-on-failure')
});
自动化脚本结构
对于无需测试运行器的独立脚本:
import { ios, android } from 'mobilewright';
const device = await ios.launch({ bundleId: 'com.example.myapp' });
const { screen } = device;
// Do your work
const tree = await screen.viewTree();
const screenshot = await screen.screenshot();
await screen.getByRole('button', { name: 'Start' }).tap();
// Always close when done
await device.close();
启动选项
// Auto-discover first booted simulator
const device = await ios.launch();
// Launch a specific app
const device = await ios.launch({ bundleId: 'com.example.app' });
// Match device by name (regex)
const device = await ios.launch({ deviceName: 'My.*iPhone' });
// Target exact device
const device = await ios.launch({ deviceId: '5A5FCFCA-...' });
// List available devices
const devices = await ios.devices();
const devices = await android.devices();
开发验证
开发移动应用时,使用 mobilewright 验证 UI 实现:
import { ios } from 'mobilewright';
const device = await ios.launch({ bundleId: 'com.example.myapp' });
const { screen } = device;
// Take a screenshot to verify the UI looks correct
const screenshot = await screen.screenshot();
// Dump the view tree to verify elements exist and have correct properties
const tree = await screen.viewTree();
// Verify specific elements are present and have expected content
const title = await screen.getByRole('text', { name: 'Welcome' }).getText();
const isButtonEnabled = await screen.getByTestId('submit-btn').isEnabled();
await device.close();
API 快速参考
Screen — 定位器工厂
screen.getByLabel('Email') // accessibility label
screen.getByTestId('login-button') // accessibility identifier
screen.getByText('Welcome') // exact text match
screen.getByText(/welcome/i) // regex match
screen.getByText('welcome', { exact: false }) // substring match
screen.getByType('TextField') // native element type
screen.getByRole('button', { name: 'Sign In' }) // semantic role + name
Screen — 直接操作
await screen.screenshot() // PNG buffer
await screen.screenshot({ format: 'jpeg', quality: 80 })
await screen.swipe('up') // scroll down
await screen.swipe('down', { distance: 300, duration: 500 })
await screen.pressButton('HOME')
await screen.goBack() // press BACK (Android only, no-op on iOS)
await screen.viewTree() // full view hierarchy as JSON
Locator — 操作
所有操作都会自动等待元素变为可见、已启用且稳定。
await locator.tap()
await locator.doubleTap()
await locator.longPress({ duration: 1000 })
await locator.fill('[email protected]') // tap to focus + type
Locator — 查询
await locator.getText() // visible text / label
await locator.getValue() // input value
await locator.isVisible() // boolean
await locator.isEnabled() // boolean
await locator.isSelected() // boolean
await locator.isFocused() // boolean
await locator.isChecked() // boolean
Locator — Collection
处理多个匹配的元素:
await locator.count() // number of matching elements
await locator.all() // array of Locator, one per match
locator.first() // first matching element
locator.last() // last matching element
locator.nth(2) // element at index (0-based, negative counts from end)
示例 — 点击列表中的第三项:
await screen.getByType('Cell').nth(2).tap();
示例 — 验证搜索结果的数量:
const count = await screen.getByType('Cell').count();
expect(count).toBe(5);
Locator — Chaining
将查询范围限定在父元素的边界内:
const row = screen.getByType('Cell');
await row.getByRole('button', { name: 'Delete' }).tap();
await row.getByPlaceholder('Enter name').fill('Arthur');
注意:getByPlaceholder 仅适用于 Locator(链式调用),不能直接用于 Screen。
Locator — Waiting
await locator.waitFor({ state: 'visible' })
await locator.waitFor({ state: 'hidden' })
await locator.waitFor({ state: 'enabled' })
await locator.waitFor({ state: 'disabled', timeout: 10_000 })
Assertions
所有断言都会自动重试,直到满足条件或超时。使用 .not 表示否定。
await expect(locator).toBeVisible();
await expect(locator).not.toBeVisible();
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toHaveText('Welcome back!');
await expect(locator).toHaveText(/welcome/i);
await expect(locator).toContainText('back');
await expect(locator).toHaveValue('[email protected]');
await expect(locator).toBeHidden();
await expect(locator).toBeChecked();
await expect(locator).toBeSelected();
await expect(locator).toBeFocused();
await expect(locator).toBeVisible({ timeout: 10_000 });
Device
// App lifecycle
await device.launchApp('com.example.app');
await device.launchApp('com.example.app', { locale: 'fr_FR' });
await device.terminateApp('com.example.app');
await device.installApp('/path/to/app.ipa');
await device.uninstallApp('com.example.app');
const apps = await device.listApps();
const foreground = await device.getForegroundApp();
// Navigation / deep links
await device.goto('myapp://settings');
await device.openUrl('https://example.com');
// Orientation
await device.setOrientation('landscape');
const orientation = await device.getOrientation();
// Recording
await device.startRecording({ output: 'recording.mp4' });
await device.stopRecording();
// Cleanup
await device.close();
Role Mapping
getByRole 会将语义角色映射到特定平台的类型:
| 角色 | iOS | Android |
|---|---|---|
button | Button, ImageButton | Button, ImageButton, ReactViewGroup* |
textfield | TextField, SecureTextField, SearchField | EditText, ReactEditText |
text | StaticText | TextView, Text, ReactTextView |
image | Image | ImageView, ReactImageView |
switch | Switch | Switch, Toggle |
checkbox | -- | Checkbox |
slider | Slider | SeekBar |
list | Table, CollectionView, ScrollView | ListView, RecyclerView, ReactScrollView |
header | NavigationBar | Toolbar |
link | Link | Link |
* 仅当 clickable="true" 或 accessible="true" 时,ReactViewGroup 才会匹配 button。
如果不存在映射,则回退到直接类型匹配。
Common Patterns
每项测试都使用全新的应用状态
test.beforeEach(async ({ device, bundleId }) => {
await device.terminateApp(bundleId).catch(() => {});
await device.launchApp(bundleId);
});
失败时截取屏幕截图(自动)
@mobilewright/test fixture 会在测试失败时自动截取屏幕截图。手动截图示例:
test.afterEach(async ({ screen }, testInfo) => {
const screenshot = await screen.screenshot();
await testInfo.attach('screenshot', { body: screenshot, contentType: 'image/png' });
});
滚动以查找屏幕外的元素
await screen.swipe('up'); // scroll down to reveal content below
await expect(screen.getByText('Footer Content')).toBeVisible();
将重复流程提取为辅助函数
async function login(screen: any, email: string, password: string) {
await screen.getByLabel('Email').fill(email);
await screen.getByLabel('Password').fill(password);
await screen.getByRole('button', { name: 'Sign In' }).tap();
}
test('logged in user sees dashboard', async ({ screen }) => {
await login(screen, '[email protected]', 'pass123');
await expect(screen.getByText('Dashboard')).toBeVisible();
});
Rules
务必做到
- 先检查 UI 树。 编写定位器前先运行
screen.viewTree()。切勿猜测。 - 使用可重试的断言。
await expect(locator).toBeVisible()会自动重试。await locator.isVisible()不会。 - 使用定位器,不要使用坐标。 定位器能够适应布局变化。
- 在测试之间重新启动应用。
terminateApp+launchApp可确保状态干净。 - 当精确措辞可能变化时,使用
{ exact: false }灵活匹配文本。 - **将重复的导航流程提取为辅助函数,**让测试更易读。
切勿这样做
- 切勿使用
screen.tap(x, y)与元素交互。 原始坐标在不同设备、屏幕尺寸和操作系统版本之间会失效。始终使用定位器查找元素。 - 切勿硬编码超时时间。 不要寄希望于元素会出现而执行
await sleep(2000)。请使用自动等待操作和可重试的断言,它们会自动处理时序问题。 - 切勿在操作前使用
waitFor。tap()和fill()等操作本身已会自动等待。编写await locator.waitFor({ state: 'visible' }); await locator.tap();是多余的,直接使用await locator.tap()即可。 - 切勿使用
isVisible()进行测试断言。 它只会返回某个瞬间的布尔值。请使用expect(locator).toBeVisible(),它会一直重试,直到断言满足。 - 切勿通过截取屏幕截图并解析图像来查找元素。 请使用
screen.viewTree()以编程方式检查无障碍树。 - 当存在角色时,切勿硬编码特定平台的类型。 使用
getByRole('button'),而不是getByType('UIButton')。