1. 项目概述:为什么是WD.js?
如果你做过移动端自动化测试,大概率对Appium和Selenium这两个名字不会陌生。Appium负责搞定手机App,Selenium负责搞定Web浏览器,它们各自为王,但当你需要在一个测试流程里同时操作App内的WebView(比如微信小程序、H5页面)和原生控件时,麻烦就来了。你得在两个不同的客户端库、两套API之间来回切换,脚本写得又长又乱,维护起来简直是噩梦。
我最近在做一个电商App的回归测试,核心流程是:启动App -> 登录 -> 进入商品详情页(原生页面) -> 点击“分享”跳转到微信小程序(WebView) -> 在小程序内完成一些操作。最开始我用的是Python,Appium部分用appium-python-client,一旦进入WebView,就得用selenium重新初始化一个driver来操作,两个driver实例的数据和上下文完全隔离,状态同步、异常处理复杂得让人头疼。
直到我遇到了WD.js。它不是一个全新的框架,而是Appium团队官方维护的一个JavaScript客户端库。它的核心价值在于,用一个统一的API,同时封装了Appium(用于移动端)和Selenium(用于Web端)的协议。这意味着,你只需要一个driver对象,就能在原生App、混合App、移动端浏览器甚至桌面浏览器之间无缝切换,底层协议细节被完美隐藏。对于前面那个电商测试场景,WD.js让我用同一段脚本、同一种写法,流畅地完成了从原生页面到WebView的所有操作,脚本的简洁度和可维护性提升了不止一个档次。
2. 环境搭建与核心依赖解析
工欲善其事,必先利其器。用WD.js之前,得先把场子搭好。这里面的坑,我几乎一个没落全踩过,尤其是环境配置和版本兼容性问题。
2.1 Node.js与包管理器选择
WD.js基于Node.js,所以第一步是安装Node.js。我的建议是,不要用系统自带的包管理器安装(比如apt-get或brew),版本可能太旧或引发权限问题。直接去Node.js官网下载LTS(长期支持)版本安装包,目前推荐18.x或20.x。安装后,在终端运行node -v和npm -v确认版本。
接下来是包管理器。npm是Node.js自带的,够用,但速度慢且依赖管理有时会出问题。我强烈推荐使用yarn或pnpm。yarn的缓存机制和确定性安装能极大避免“在我机器上是好的”这种问题。安装命令很简单:
npm install -g yarn或者用更快的pnpm:
npm install -g pnpm项目初始化时,用yarn init -y或pnpm init创建package.json文件。
2.2 安装WD.js与测试框架
WD.js是核心库,但我们通常需要搭配一个测试框架(如Mocha、Jest)和一个断言库(如Chai)来组织测试用例。这里以yarn和Mocha为例:
# 安装WD.js yarn add wd # 安装测试框架和断言库 yarn add mocha chai --save-dev # 可选:安装用于生成漂亮测试报告的库 yarn add mochawesome --save-dev这里有个关键点:WD.js的版本。一定要查看其官方GitHub仓库的Release Notes。我曾经因为没注意,用了较新的Node.js版本搭配一个旧的WD.js,结果在建立WebSocket连接时一直报错。目前(以当下时间点为例)WD.js 1.x版本对Appium 2.x和Selenium 4.x支持较好。
2.3 Appium Server的安装与配置
WD.js是客户端,它需要连接Appium Server,由Appium Server再去驱动手机或模拟器。安装Appium Server有两种主流方式:
通过npm全局安装:这是最直接的方式。
npm install -g appium安装后,在终端输入
appium即可启动服务,默认监听4723端口。但这种方式安装的Appium,其相关的驱动(如UiAutomator2驱动、XCUITest驱动)需要单独安装。使用Appium Desktop:这是一个带图形界面的工具,非常适合新手和调试。它内置了Appium Server和Inspector(用于定位元素)。从官网下载安装即可。启动后,点击“Start Server”按钮,同样会启动在4723端口。
注意:无论哪种方式,务必确保你安装的Appium版本是2.x。Appium 1.x已停止维护,很多新特性和驱动在2.x上。安装后,通过
appium -v或Appium Desktop的关于页面查看版本。
安装完Appium Server后,必须安装对应平台的驱动。对于Android测试,你需要uiautomator2驱动;对于iOS,需要xcuitest驱动。通过Appium 2.0的命令行工具安装:
# 安装Android驱动 appium driver install uiautomator2 # 安装iOS驱动 (需要在macOS系统下) appium driver install xcuitest可以通过appium driver list来查看已安装的驱动。
2.4 移动端测试环境准备(以Android为例)
这是最繁琐但也最重要的一步,很多失败都源于环境问题。
- 安装Java JDK:Appium的部分组件需要Java环境。安装JDK 8或11(LTS版本),并配置
JAVA_HOME环境变量。 - 安装Android SDK:推荐通过Android Studio安装,它管理SDK版本和构建工具最方便。安装后,需要配置
ANDROID_HOME环境变量(指向SDK根目录),并把$ANDROID_HOME/platform-tools和$ANDROID_HOME/tools目录添加到系统的PATH中。 - 准备设备:可以是真机,也可以是模拟器(如Android Studio自带的AVD)。真机需要开启“开发者选项”和“USB调试”。模拟器需要先创建并启动一个虚拟设备。
- 关键检查:在终端执行
adb devices。如果能看到你的设备或模拟器列表,说明ADB连接正常。这是后续所有操作的基础。
3. WD.js核心API与第一个测试脚本
环境配好了,我们来写第一个脚本。WD.js的API设计是链式调用(Promise-based),理解它的几个核心方法就能上手。
3.1 初始化Driver与基础配置
首先,创建一个测试文件,比如first_test.js。我们需要引入WD库,并配置连接Appium Server所需的能力(Capabilities)。Capabilities是一组键值对,用来告诉Appium你要测试什么设备、什么应用。
const wd = require('wd'); // 1. 配置Appium Server的地址 const serverConfig = { hostname: 'localhost', // 如果Appium Server跑在本机 port: 4723, // 默认端口 path: '/wd/hub' // Appium 2.0的默认路径 }; // 2. 配置设备能力(Capabilities) // 这里以Android真机测试一个计算器App为例 const androidCapabilities = { platformName: 'Android', // 平台 'appium:automationName': 'UiAutomator2', // 自动化引擎,必须指定 'appium:deviceName': '你的设备名', // 通过 `adb devices` 查看 'appium:platformVersion': '13', // 安卓系统版本 'appium:appPackage': 'com.android.calculator2', // 被测App的包名 'appium:appActivity': 'com.android.calculator2.Calculator', // 启动的Activity名 'appium:noReset': true // 不重置应用状态,避免每次清空数据 }; // 3. 创建driver实例 const driver = wd.promiseChainRemote(serverConfig); // 4. 定义一个简单的测试套件(使用async/await语法更清晰) (async function runTest() { try { // 初始化会话,连接设备和App await driver.init(androidCapabilities); console.log('会话创建成功!'); // 在这里编写你的测试操作... // 例如:点击数字按钮 const button7 = await driver.elementById('com.android.calculator2:id/digit_7'); await button7.click(); // 休眠2秒,方便观察 await driver.sleep(2000); // 结束会话,关闭App await driver.quit(); console.log('测试完成!'); } catch (error) { console.error('测试过程中发生错误:', error); // 即使出错,也尝试退出会话,避免占用端口 await driver.quit(); } })();实操心得:
appium:前缀是W3C WebDriver标准格式,在Appium 2.x中推荐使用。deviceName在Android上其实不是必须的,但写上是个好习惯。获取appPackage和appActivity最准确的方法是在手机打开该App后,在终端执行adb shell dumpsys window | grep mCurrentFocus。
3.2 元素定位策略详解
定位不到元素是自动化测试中最常见的问题。WD.js支持Selenium WebDriver的全部定位策略,因为它的底层就是WebDriver协议。
八大定位策略(By):
- ID(
driver.elementById):首选。对于Android,通常是resource-id;对于iOS,是accessibility id或name。 - Accessibility ID(
driver.elementByAccessibilityId):在移动端,这通常对应元素的content-desc或accessibilityIdentifier,是为无障碍服务设计的,非常适合做定位。 - XPath(
driver.elementByXPath):功能最强大,但性能最差,且容易因UI改动而失效。不到万不得已,不要用。如果要用,尽量用相对路径和非索引依赖。 - Class Name(
driver.elementByClassName):定位一类元素,如android.widget.Button。通常一个界面上同类元素太多,不精确。 - CSS Selector(
driver.elementByCssSelector):仅适用于WebView中的网页元素,在原生控件中无效。 - Android UIAutomator(
driver.elementByAndroidUIAutomator):Android专属,功能强大,可以用UiAutomator API的表达式定位,如new UiSelector().text(\"确定\")。 - iOS Predicate String(
driver.elementByIosUIAutomation):iOS专属,类似XPath但效率更高,语法如type == \"XCUIElementTypeButton\" AND label == \"提交\"。 - iOS Class Chain(
driver.elementByIosClassChain):iOS专属,比Predicate更结构化,定位速度更快。
我的定位策略优先级:
- 首选 Accessibility ID:如果开发同学规范地设置了,这是最稳定、语义化最好的方式。
- 次选 Resource ID (Android) / Name (iOS):原生提供的唯一标识。
- 使用UIAutomator/Predicate:当上述都没有时,用平台专属的定位器,比XPath可靠。
- XPath是最后的手段:并且要拉着开发同学一起Review,看能否为关键元素加上测试ID。
3.3 常用交互API实战
找到元素后,就是与之交互。WD.js的API返回的是Promise,使用async/await能让代码清晰得像写同步代码一样。
// 点击操作 await driver.elementById('button_id').click(); // 输入文本(会在输入前先清空) await driver.elementById('input_id').sendKeys('Hello WD.js'); // 获取元素文本内容 let text = await driver.elementById('text_view_id').text(); console.log('获取到的文本是:', text); // 获取元素属性(如是否启用、是否选中) let isEnabled = await driver.elementById('button_id').getAttribute('enabled'); let isChecked = await driver.elementById('checkbox_id').getAttribute('checked'); // 滑动操作(从一点滑动到另一点) let startX = 500, startY = 1500, endX = 500, endY = 500; await driver.execute('mobile: swipe', { startX, startY, endX, endY, duration: 800 // 滑动持续时间,单位毫秒 }); // 等待元素出现(隐式等待不够用时) await driver.waitForElementById('loading_spinner', 10000); // 最多等10秒注意事项:
sendKeys对于有些输入框,可能不会触发键盘的“完成”或“搜索”事件。这时候可能需要配合driver.pressKeyCode(66)(66是回车键的键码)来模拟按下回车。
4. 实现Appium与Selenium的无缝切换
这是WD.js的“杀手级”特性。场景:你的App里嵌了一个WebView(比如用于登录、支付或展示富文本),你需要先操作原生部分,然后进入WebView操作,最后再返回原生。
4.1 理解上下文(Context)
在移动混合App中,“上下文”是一个核心概念。默认启动后,Driver处于NATIVE_APP上下文,可以操作所有原生控件。当App内打开一个WebView时,就会多出一个或多个WEBVIEW_*的上下文。
关键步骤:
- 获取所有可用上下文:
let contexts = await driver.contexts();。这会返回一个数组,如['NATIVE_APP', 'WEBVIEW_com.yourapp.package']。 - 切换到WebView上下文:
await driver.context(contexts[1]);。切换后,所有WD.js的API就变成了操作网页DOM,你可以像用Selenium测试PC浏览器一样,使用CSS选择器、执行JavaScript等。 - 切回原生上下文:
await driver.context('NATIVE_APP');。
4.2 实战:混合App登录流程
假设一个App,登录按钮是原生的,点击后跳转到一个H5登录页面(WebView),输入账号密码后提交,再跳回原生首页。
const wd = require('wd'); const driver = wd.promiseChainRemote('localhost', 4723); (async () => { const caps = { platformName: 'Android', 'appium:automationName': 'UiAutomator2', 'appium:deviceName': 'Android', 'appium:appPackage': 'com.example.myapp', 'appium:appActivity': '.MainActivity', 'appium:chromedriverExecutable': '/path/to/chromedriver', // 关键!用于匹配WebView的Chrome版本 'appium:autoWebview': false // 我们不希望自动切换 }; await driver.init(caps); try { // --- 步骤1: 原生界面操作 --- console.log('当前上下文:', await driver.currentContext()); // 应该是 NATIVE_APP // 点击原生登录按钮 await driver.elementByAccessibilityId('login_button').click(); await driver.sleep(3000); // 等待WebView加载 // --- 步骤2: 切换到WebView --- let contexts = await driver.contexts(); console.log('所有上下文:', contexts); // 找到WEBVIEW开头的上下文 let webviewContext = contexts.find(ctx => ctx.startsWith('WEBVIEW_')); if (!webviewContext) { throw new Error('未找到WebView上下文!'); } await driver.context(webviewContext); console.log('切换到上下文:', await driver.currentContext()); // 现在可以用Selenium的方式操作H5页面了 // 假设是标准的HTML输入框 await driver.elementByCssSelector('input[name=\"username\"]').sendKeys('testuser'); await driver.elementByCssSelector('input[name=\"password\"]').sendKeys('password123'); await driver.elementByCssSelector('button[type=\"submit\"]').click(); // 等待登录完成,页面跳转或消失 await driver.sleep(2000); // --- 步骤3: 切回原生上下文 --- await driver.context('NATIVE_APP'); console.log('切回上下文:', await driver.currentContext()); // 验证登录成功,比如检查用户头像是否出现 let avatar = await driver.waitForElementByAccessibilityId('user_avatar', 5000); if (avatar) { console.log('✅ 混合登录流程测试通过!'); } } catch (error) { console.error('❌ 测试失败:', error); } finally { await driver.quit(); } })();避坑指南:这里最大的坑是
chromedriverExecutable。Appium需要通过ChromeDriver来驱动WebView。你必须确保安装的ChromeDriver版本与手机/模拟器内WebView(或Chrome浏览器)的版本兼容。最好通过appium --allow-insecure chromedriver_autodownload让Appium自动管理,或者手动下载匹配的版本并在Capabilities中指定路径。
5. 高级技巧与最佳实践
当基础功能跑通后,要写出健壮、可维护的测试脚本,还需要一些高级技巧和架构思维。
5.1 页面对象模型(Page Object Pattern)改造
直接在测试用例里写driver.elementById(...).click()会很快导致代码难以维护。页面对象模型(POP)将页面元素和操作封装成类,是业界标准的最佳实践。
// pages/LoginPage.js class LoginPage { constructor(driver) { this.driver = driver; // 定义元素选择器(定位器) this.locators = { usernameInput: '#username', // WebView中用CSS passwordInput: by.accessibilityId('password_field'), // 原生中用其他定位方式 submitButton: by.id('com.example.app:id/login_btn') }; } async switchToWebViewContext() { const contexts = await this.driver.contexts(); const webview = contexts.find(c => c.startsWith('WEBVIEW_')); if (webview) await this.driver.context(webview); return this; } async switchToNativeContext() { await this.driver.context('NATIVE_APP'); return this; } async login(username, password) { // 这个方法里封装了可能的上下文切换逻辑 await this.switchToWebViewContext(); await this.driver.elementByCss(this.locators.usernameInput).sendKeys(username); await this.driver.elementByCss(this.locators.passwordInput).sendKeys(password); await this.switchToNativeContext(); await this.driver.elementById(this.locators.submitButton).click(); } } module.exports = LoginPage; // 在测试用例中使用 const LoginPage = require('./pages/LoginPage'); describe('登录测试', function() { it('应该能成功登录', async function() { const loginPage = new LoginPage(driver); await loginPage.login('user', 'pass'); // 添加断言... }); });这样,当登录页面的UI发生变化时,你只需要修改LoginPage.js文件中的locators和login方法,所有测试用例都不需要改动。
5.2 等待策略:告别硬编码的sleep
driver.sleep(3000)是脆弱的,网络或设备慢一点就可能失败。必须使用智能等待。
隐式等待(Implicit Wait):在初始化driver后设置,针对所有
findElement操作生效。await driver.setImplicitWaitTimeout(10000); // 10秒但它对元素是否可点击、是否可见无效。
显式等待(Explicit Wait):推荐使用。等待某个特定条件成立。
const { until, By } = require('selenium-webdriver'); // WD.js兼容Selenium的等待条件 // 等待元素可见 let element = await driver.wait( until.elementLocated(By.id('success_toast')), 15000, // 超时时间 '成功提示框没有在15秒内出现' // 超时错误信息 ); // 等待元素可点击 await driver.wait(until.elementIsVisible(element), 5000); await driver.wait(until.elementIsEnabled(element), 5000);WD.js也自带了一些等待方法,如
driver.waitForElementById('id', timeout),但其条件比较单一。
5.3 异常处理与截图记录
测试失败时,一张截图抵得上千行日志。WD.js可以方便地截图,并配合测试框架的afterEach钩子使用。
const fs = require('fs'); const path = require('path'); describe('某个功能模块', function() { // 每个测试用例结束后执行 afterEach(async function() { if (this.currentTest.state === 'failed') { // 获取用例名作为截图文件名 const testName = this.currentTest.title.replace(/\s+/g, '_'); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const screenshotPath = path.join(__dirname, 'screenshots', `${testName}_${timestamp}.png`); // 创建目录(如果不存在) const dir = path.dirname(screenshotPath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); // 截图并保存 const screenshot = await driver.takeScreenshot(); fs.writeFileSync(screenshotPath, screenshot, 'base64'); console.log(`⚠️ 测试失败,截图已保存至: ${screenshotPath}`); } }); it('一个可能会失败的测试', async function() { // ... 测试逻辑 throw new Error('故意失败!'); }); });同时,在try...catch块中,可以捕获更具体的错误,如NoSuchElementError(元素未找到)、StaleElementReferenceError(元素引用失效)等,并做出不同的处理或重试。
5.4 并行测试与Grid配置
当测试用例越来越多时,串行执行太慢。可以利用Selenium Grid的思路,搭建一个测试集群。
- 启动Appium Server节点:在多台机器或同一台机器的不同端口上启动多个Appium Server实例,每个实例连接不同的手机或模拟器。
- 配置WD.js连接Grid:WD.js的
promiseChainRemote可以指定Grid Hub的地址。const driver = wd.promiseChainRemote({ hostname: 'grid-hub-ip', port: 4444, // Grid Hub默认端口 path: '/wd/hub' }); - 在Capabilities中指定目标设备:通过
appium:udid(设备唯一标识)来指定测试要运行在哪台设备上。你的测试框架(如Mocha)可以配合async库或原生Promise.all来并发执行多个测试任务。
6. 常见问题排查与调试技巧
在实际项目中,你会遇到各种各样稀奇古怪的问题。这里记录了我踩过的一些典型深坑和解决方法。
6.1 连接与会话问题
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
Could not find a driver for... | 1. Appium Server未安装对应平台的驱动。 2. Capabilities中 automationName拼写错误或未指定。 | 1. 运行appium driver list确认已安装uiautomator2或xcuitest。2. 运行 appium driver install <driver-name>安装。3. 检查Capabilities,确保有 'appium:automationName': 'UiAutomator2'。 |
Unable to connect to Appium server | 1. Appium Server未启动。 2. 主机名或端口错误。 3. 防火墙阻止。 | 1. 在终端运行appium或启动Appium Desktop,确认输出无报错,并显示listening on 0.0.0.0:4723。2. 用浏览器或 curl访问http://localhost:4723/wd/hub/status,应返回JSON响应。3. 检查代码中的 hostname和port。 |
A new session could not be created | 1. 设备未连接或未授权。 2. 指定的App包名/Activity名错误。 3. 设备系统版本与驱动不兼容。 | 1. 运行adb devices确认设备在线且状态为device。2. 真机检查是否弹出“允许USB调试”提示。 3. 使用 adb shell pm list packages和adb shell dumpsys activity确认正确的包名和Activity。4. 尝试更换模拟器或真机的系统版本。 |
6.2 元素定位与交互问题
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
NoSuchElementError | 1. 定位器写错了。 2. 元素尚未加载出来。 3. 元素在WebView里,但上下文还在 NATIVE_APP。 | 1. 使用Appium Desktop的Inspector或Android Studio的Layout Inspector重新检查元素属性。 2. 添加显式等待,等待元素出现。 3. 打印 await driver.contexts()和await driver.currentContext(),确认当前上下文是否正确。 |
StaleElementReferenceError | 之前找到的元素,因为页面刷新或重建,已经失效。 | 这是动态页面的常见问题。解决方案是“用时再找”,不要长期持有元素对象。将元素定位代码封装在函数或Page Object的方法内部,每次操作前重新查找。 |
Element is not clickable at point | 1. 元素被遮挡(如弹窗、蒙层)。 2. 元素实际不可点击(如 enabled=false)。3. 坐标点计算错误(多见于滑动操作)。 | 1. 检查UI层级,关闭可能的弹窗。 2. 使用 getAttribute('enabled')检查元素状态。3. 尝试用 element.click()代替坐标点击。对于滑动,使用mobile: swipe或mobile: scroll等更稳定的API。 |
sendKeys不生效 | 1. 焦点不在输入框。 2. 输入框是自定义控件,非标准输入框。 3. 需要先清空内容。 | 1. 先对输入框执行一次click()。2. 尝试使用 driver.execute('mobile: type', {text: 'xxx'})或driver.pressKeyCode模拟键盘输入。3. 先执行 element.clear()。 |
6.3 WebView相关疑难杂症
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
找不到WEBVIEW_上下文 | 1. App的WebView未开启调试模式。 2. Appium的ChromeDriver版本不匹配。 | 1.对于Android:需要在App代码中为WebView设置WebView.setWebContentsDebuggingEnabled(true),并对调试版App生效。2.通用方案:在Capabilities中设置 appium:chromedriverExecutableDir指向一个包含多个版本ChromeDriver的目录,或设置appium:chromedriverChromeMappingFile。3. 查看Appium Server日志,搜索 Chromedriver相关的错误信息。 |
| 在WebView上下文中,CSS定位器失效 | 1. 页面内有iframe。 2. 元素在Shadow DOM内。 | 1. 使用driver.frame()切换到对应的iframe。2. 对于Shadow DOM,需要使用JavaScript执行 document.querySelector(...).shadowRoot.querySelector(...)来穿透查找。WD.js可以通过driver.executeScript()执行这段JS。 |
| 切换回原生上下文后,找不到原生元素 | 页面发生了跳转或Activity切换,旧的原生元素句柄失效。 | 这是一个常见的“上下文陷阱”。切换回NATIVE_APP后,如果页面已变,需要重新查找元素。确保你的页面对象或操作逻辑在关键步骤后能重新初始化或查找元素。 |
6.4 性能与稳定性调优
- 减少不必要的截图:截图操作很耗时,只在失败或关键步骤时进行。
- 使用
noReset和fullReset:noReset: true可以避免每次测试都重装App,节省大量时间。但需要注意测试间的数据隔离。fullReset: true则会在会话结束后彻底清除App数据。 - 优化Capabilities:
appium:skipDeviceInitialization: 跳过一些设备初始化检查,可以加快会话创建。appium:skipServerInstallation: 跳过在设备上安装Appium组件的步骤(如果已经安装过)。
- 会话复用:对于一组相关的测试用例,考虑复用同一个Driver会话,而不是每个用例都
init和quit。但要做好清理工作,避免用例间相互影响。
调试时,一定要多看Appium Server的日志。启动Appium时加上--log-level debug,或者查看Appium Desktop的日志输出,里面包含了客户端发送的每一个请求和服务器的响应,是定位问题的金钥匙。