mcpField
M
mobilewright

mobilewright

github.com/mobile-next测试与质量389 次安装29 周前更新
代码仓库

关于 mobilewright

mobilewright 是面向 iOS 和 Android 的 Playwright 风格框架与 Skill,用于应用测试、设备自动化和 UI 验证。它支持 TypeScript 与 JavaScript,并提供视图树检查和定位器交互。

主要功能

  1. 01iOS 与 Android 设备自动化
  2. 02端到端、回归及冒烟测试
  3. 03检查视图树和屏幕截图
  4. 04基于定位器的 UI 交互
  5. 05测试 Fixture 与自动重试断言
  6. 06用于配置、设备和测试的 CLI

适用场景

  1. 01运行移动应用回归测试
  2. 02自动执行重复的设备操作
  3. 03在设备上检查新实现的 UI
  4. 04采集屏幕截图和无障碍视图树

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/mobile-next/mobilewright-skill --skill mobilewright

安装后在 Claude Code 中输入/mobilewright确认已加载。

详细介绍

Mobilewright Skill

使用 mobilewright 自动化移动设备、进行测试和开发验证——这是一个受 Playwright 启发、适用于 iOS 和 Android 的框架。

在以下情况下使用此技能:

  • 编写移动应用测试(端到端、回归、冒烟测试)
  • 自动执行移动设备交互(抓取、截图、重复性任务)
  • 在移动应用开发期间验证 UI 实现——截取屏幕截图、导出视图树,并确认你编写的代码产生了预期结果

第一步

编写任何代码之前,先收集相关信息:

  1. 你要做什么? 询问用户:测试、自动化脚本,还是验证刚构建的 UI?
  2. 使用什么平台? 询问用户:iOS、Android,还是两者都用?
  3. 使用 TypeScript 还是 JavaScript? 询问用户。两者均受支持。
  4. 自动查找 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
    • 只有在无法自动找到时,才询问用户
  5. 是否已有 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)
  • 占位文本

然后按以下优先级选择定位器:

定位器优先级(从优到次)
优先级定位器原因
1getByTestId('login-btn')最稳定。由开发者设置,不会随文案或国际化而改变。
2getByRole('button', { name: 'Sign In' })语义明确且跨平台。在 iOS 和 Android 上都映射到原生类型。
3getByLabel('Sign In')无障碍标签。只要持续维护无障碍信息,就很稳定。
4getByPlaceholder('Search...')适用于文本字段。仅 Locator 支持,Screen 不支持。
5getByText('Sign In')不稳定。文案更新、国际化或轻微措辞调整都会导致变化。
6getByType('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测试设备屏幕——用于所有元素交互
deviceworker设备连接——用于应用生命周期、方向和 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 会将语义角色映射到特定平台的类型:

角色iOSAndroid
buttonButton, ImageButtonButton, ImageButton, ReactViewGroup*
textfieldTextField, SecureTextField, SearchFieldEditText, ReactEditText
textStaticTextTextView, Text, ReactTextView
imageImageImageView, ReactImageView
switchSwitchSwitch, Toggle
checkbox--Checkbox
sliderSliderSeekBar
listTable, CollectionView, ScrollViewListView, RecyclerView, ReactScrollView
headerNavigationBarToolbar
linkLinkLink

* 仅当 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')。

评论

支持 Markdown