ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

iOS自动化测试进阶:用Cucumber实现BDD驱动与稳定性优化

iOS自动化测试进阶:用Cucumber实现BDD驱动与稳定性优化

1. 为什么我的iOS自动化最终选了Cucumber

1.1 三条主流路线,我为什么没选纯XCTest

开始之前,先把iOS自动化测试的几条路线摆开。我做了几年移动端测试,前后接触过三类方案:纯XCTest/XCUITest方案、Appium搭配各种框架的方案、以及Cucumber这类BDD工具驱动的方案。纯XCTest的好处是原生、稳定、不依赖第三方环境,但问题也很现实——用例全部写死在代码里,测试代码和业务代码糅在一起,产品经理和业务同事看不懂你在验什么。团队一旦扩张,沟通成本立刻变成瓶颈。

后来我换成了Cucumber。它的定位不是替代XCTest,而是在XCTest之上加了一层“可读的行为描述”。你可以先用Gherkin语法写一段人类能读懂的测试场景,再通过步骤定义把场景映射到真实的自动化操作上。这样一来,测试用例的每一行都像产品需求里的User Story,谁都能看懂,而且跑测试时系统会告诉你“哪个步骤挂了、期望是什么、实际是什么”,定位问题的速度快很多。

我选Cucumber还有一个很实际的原因:它允许测试逻辑和驱动逻辑分离。同样是登录场景,换个页面布局,我只需要改封装的页面操作代码,场景描述文件可以原封不动。对频繁变动的业务来说,这种隔离带来的维护成本下降非常明显。

1.2 BDD改变的不只是代码,是协作方式

BDD,行为驱动开发,核心思想是“用业务语言描述系统行为”。举个例子,过去我写测试用例是这个画风:输入框A输入手机号、输入框B输入密码、点击按钮C、断言页面出现某元素。这套写法没问题,但产品经理看到之后只会一脸茫然,他们真正关心的是“用户能否用正确账号登录”。

换成Cucumber之后,同样的用例变成这样:给定我在登录页面,当我输入正确的账号和密码,并且点击登录按钮,那么我应该看到首页。这种描述方式有几个直接收益。第一,产品、开发、测试打开同一个文件,扯皮空间大幅压缩;第二,用例本身就是活文档,业务规则变更时改文档和改代码同步进行,不容易出现“代码改了文档没改”的经典事故;第三,新同学上手快,看得懂场景才能更好地维护步骤定义。

我在实际项目里还发现一个额外的价值——Cucumber的场景描述能反推需求遗漏。写“当我点击登录按钮”的时候,自然会追问“那用户名或密码错误呢?”,于是场景会补出异常分支。测试用例的覆盖度往往就这么一点点被逼出来的。

1.3 整体架构一句话说清楚

Cucumber本身不驱动iOS,它驱动的是“步骤定义”,真正的设备操作由Appium或XCUITest来做。我的方案实际是这样一条链路:Gherkin场景文件 -> Cucumber解析并匹配步骤定义 -> Ruby步骤定义调用Appium客户端库 -> Appium通过XCUITest驱动发送指令到iPhone模拟器或真机 -> 操作后的页面状态再传回来做断言。

这个架构最大的好处是每一层职责单一。Gherkin文件只描述业务行为,步骤定义只负责把行为翻译成代码,页面操作封装只做元素定位和交互,最底层由XCUITest保证和操作系统的兼容性。任何一个环节出了问题,都能快速定位到层,不用在一大坨测试脚本里翻来翻去。

2. iOS自动化测试环境搭建全流程

2.1 环境清单:一台已经装好Xcode的Mac还差什么

环境搭建是大多数人倒下的地方,我拆细一点。最基础的要求是一台macOS系统的电脑,装上最新稳定版Xcode,并且确认能跑模拟器。Xcode安装完先在终端执行一次xcode-select --install,把命令行工具装好,这一步很多人会漏,后面Appium编译WebDriverAgent的时候会直接报错。

除此之外,还要准备下面这些:

  • Homebrew,macOS的包管理器,后面装各种依赖都用它
  • rbenv,用来管理Ruby版本,避免污染系统Ruby
  • Ruby和Bundler,Cucumber是Ruby生态的工具
  • Appium,移动端自动化框架,通过npm安装
  • Appium的XCUITest驱动,专门负责和iOS设备通信

我的建议是全部写在项目里用Bundler管理,gem版本固定下来,团队其他人clone代码后一条命令就能复现环境。自由发挥版本号是环境问题最大的幕后黑手,没有之一。

2.2 Ruby环境与Cucumber安装

先装rbenv,终端执行:

brew install rbenv echo 'eval "$(rbenv init -)"' >> ~/.zshrc source ~/.zshrc

然后安装指定版本的Ruby,我项目里用的是3.2.x,不建议再低,因为新版Cucumber对旧Ruby的兼容性已经在变差:

rbenv install 3.2.2 rbenv global 3.2.2 ruby -v

看到版本号确认无误后,创建项目目录并安装Cucumber。我习惯用Gemfile管理依赖,这样团队协作时版本不会漂移:

# Gemfile source 'https://rubygems.org' gem 'cucumber' gem 'appium_lib' gem 'rspec-expectations' gem 'rake'

执行:

bundle install

装完之后跑一下cucumber --version,能输出版本号说明这一步成了。这里有个常见坑:如果你不小心用系统自带的Ruby直接gem install,很大概率会遇到权限问题,或者装出来的Cucumber版本和系统组件冲突。rbenv的意义就在这里,它让你项目的Ruby环境可控、可复现,不用和系统较劲。

注意:Gemfile里的rspec-expectations是断言库,Cucumber本身不带断言能力,需要单独引入。步骤定义里的expect(...).to eq(...)就靠它。

2.3 Appium与XCUITest驱动安装

Appium我用的2.x版本,安装方式很简单:

npm install -g appium appium --version

2.x版本最大的变化是驱动模块化,默认不带任何平台驱动,需要手动安装XCUITest驱动:

appium driver install xcuitest

装完可以用appium driver list确认。这一步经常会卡在下载WebDriverAgent的依赖上,如果下载慢或者失败,多半是网络问题,可以切换镜像源或者检查网络环境。驱动装好后,用appium-doctor做一次体检,它会检查Xcode、Node、npm、FFmpeg等环境变量是否齐全:

npm install -g appium-doctor appium-doctor

看到和iOS相关的检查项都是绿勾,就可以放心进入下一步了。这里的逻辑是:Appium负责对外暴露一套统一接口,XCUITest驱动才是真正下发指令到iOS的人。你可以把Appium理解成翻译官,把Cucumber发来的操作翻译成iOS能听懂的指令。

2.4 从模拟器到真机的设备准备

模拟器相对简单,Xcode自带。先在Xcode里创建好需要的模拟器,比如iPhone 14系列的某个版本,然后可以在命令行预启动。真正复杂的是真机。

真机调试需要做好三件事:开发者证书、UDID、以及信任关系。在Xcode里用Apple ID登录后,签名你的测试App,插上iPhone,在设备管理器里确认能识别到设备,然后在手机设置里信任开发者。这一套流程没走通之前,Appium连真机时会一直报unable to start webdriveragent session。

设备这块我强烈建议先用模拟器跑通全流程,再上真机。真机的不可控因素太多,WDA构建时间、证书签名、系统弹窗权限,每一项都能吞掉大半天。模拟器的目的就是先验证Cucumber链路本身有没有问题,链路通了再逐个解决真机差异。

3. 第一个Cucumber自动化场景落地

3.1 用Gherkin描述登录场景

环境就绪,进入正题。我以最经典的登录功能为例,完整走一遍BDD流程。先建好基础目录结构,这是Cucumber约定俗成的组织方式:

project/ features/ login.feature step_definitions/ login_steps.rb support/ env.rb hooks.rb

登录场景写在login.feature里,文件内容如下:

# language: zh-CN 功能: 登录 作为普通用户 我希望使用手机号和密码登录 以便进入应用主界面 场景: 使用正确的账号密码登录成功 假如我在登录页面 当我输入手机号"13800138000"和密码"abc123" 并且点击登录按钮 那么我应该看到首页 场景: 使用错误的密码登录失败 假如我在登录页面 当我输入手机号"13800138000"和密码"wrongpass" 并且点击登录按钮 那么我应该看到密码错误提示

Gherkin的关键词是功能、场景、假如、当、那么、并且,它们分别对应Given、When、Then、And。从业务视角看,每个场景都是在描述一个完整的行为;从自动化视角看,每个步骤都是待执行的原子操作。

提示:文件第一行的# language: zh-CN是告诉Cucumber用中文关键词解析。如果团队习惯英文,可以不加这行,直接用Given/When/Then。

3.2 页面对象与步骤定义实现

现在写步骤定义,我用的模式是“页面对象+步骤定义”。页面对象把元素定位和操作封装成方法,步骤定义里只做业务层面的调度,这样feature文件、步骤定义、页面代码三层各司其职。

先封装登录页面对象,新建features/support/ios/pages/login_page.rb:

class LoginPage def initialize(driver) @driver = driver end def phone_input @driver.find_element(:accessibility_id, 'login_phone_input') end def password_input @driver.find_element(:accessibility_id, 'login_password_input') end def login_button @driver.find_element(:accessibility_id, 'login_button') end def home_page @driver.find_element(:accessibility_id, 'home_page_title') end def error_tip @driver.find_element(:accessibility_id, 'login_error_tip') end end

这里的accessibility_id对应iOS里的Accessibility Identifier,是UI元素定位的首选方式。为什么首选它?因为它和视觉文案解耦,不会因为按钮文字从“登录”改成“登 录”就挂掉,也比XPath稳定得多。

接着写步骤定义,新建features/step_definitions/login_steps.rb:

假如(/^我在登录页面$/) do @login_page = LoginPage.new(@driver) expect(@login_page.phone_input.displayed?).to be true end 当(/^我输入手机号"([^"]*)"和密码"([^"]*)"$/) do |phone, password| @login_page.phone_input.send_keys(phone) @login_page.password_input.send_keys(password) end 并且(/^点击登录按钮$/) do @login_page.login_button.click end 那么(/^我应该看到首页$/) do expect(@login_page.home_page.displayed?).to be true end 那么(/^我应该看到密码错误提示$/) do expect(@login_page.error_tip.displayed?).to be true end

步骤定义的核心机制是正则匹配。当(/^我输入手机号"([^"]*)"和密码"([^"]*)"$/)会从feature文件里抓出引号里的两个值,分别传给phone和password变量。注意正则里的^和$必须写全,否则会出现歧义匹配,Cucumber会报多个步骤定义命中的错误。

这一步我踩过最大的坑是send_keys在iOS上有时候不触发键盘事件。后来统一在Appium的Capabilities里加了unicodeKeyboard相关配置,并在需要输入前确保元素获得焦点。iOS下偶尔还要主动@driver.hide_keyboard收起键盘再点按钮,否则按钮被键盘遮挡会导致点击失效。

3.3 配置、钩子与run脚本

有了页面对象和步骤定义,还要配好Driver初始化和钩子。features/support/env.rb里负责建立全局的Appium驱动:

require 'appium_lib' require 'rspec/expectations' caps = { platformName: 'iOS', platformVersion: ENV['PLATFORM_VERSION'] || '16.4', deviceName: ENV['DEVICE_NAME'] || 'iPhone 14', automationName: 'XCUITest', app: ENV['APP_PATH'], udid: ENV['UDID'], noReset: true } Appium::Driver.new(caps, true) Appium.promote_appium_methods(Object)

Appium.promote_appium_methods(Object)这句话是把Appium的find_element、click等方法挂到全局对象上,步骤定义里用起来最方便。正式项目中我更推荐只在页面对象类里include Appium::Api,避免全局污染,但小项目怎么省事怎么来。

features/support/hooks.rb里处理驱动生命周期:

Before do @driver.start_driver end After do |scenario| if scenario.failed? timestamp = Time.now.strftime('%Y%m%d_%H%M%S') @driver.screenshot("../screenshots/failure_#{timestamp}.png") end @driver.driver_quit end

两个细节值得说明。第一,After钩子里判断失败并截图,这是排查用例失败的头号信息来源。第二,每个场景前后都重启驱动,虽然慢一点,但换来的是测试隔离——上一个场景登录过的状态不会污染下一个场景。为了效率,我后来改成如果场景没有失败就复用驱动,减少重复启动的耗时,但也带来了状态管理复杂度,新手不建议上来就这样优化。

最后写一个Rakefile把运行流程串起来:

require 'cucumber/rake/task' Cucumber::Rake::Task.new(:run) do |t| t.profile = 'default' end task default: :run

再用cucumber --init生成默认配置,然后在config/cucumber.yml里定义profile:

default: --format pretty --format json --out reports/report.json --format html --out reports/report.html

3.4 运行与调试

一切就绪,启动Appium服务:

appium

另开一个终端跑测试:

bundle exec rake run

Cucumber会逐个解析feature文件的步骤,黄色步骤表示待执行、绿色通过、红色失败。我第一次跑通登录场景的时候特别注意了一下执行日志,里面对每一步的耗时、状态、失败原因记录得一清二楚,比纯XCTest的控制台输出更适合解决跨前后端沟通的问题。

跑完之后打开reports/report.html,你会看到一份带步骤描述、执行结果、失败截图的完整报告。报告可以直接丢给产品同学看,不用解释任何代码细节,这也是BDD方案在项目管理上最好用的一面。我是习惯把每次CI跑完的报告归档的,按日期存好,某天线上出问题,翻历史报告能快速定位是哪次改动导致的回归。

4. 运行一年遇到的高频问题与排查方法

4.1 环境层面的坑,一套排查顺序记好

环境问题占了所有问题的一半,我把最高频的几个排个序,按这个顺序排查能省大量时间。

第一,xcode-select指向路径不对,或模拟器运行时版本和Xcode不匹配。解决方案是xcode-select -s /Applications/Xcode.app/Contents/Developer,并确认xcrun simctl list能列出你需要的模拟器。

第二,Ruby版本混乱。很多人项目里ruby -v看到的还是系统自带版本,结果安装的gem和Bundler锁定的版本对不上。先rbenv versions确认当前版本,再rbenv local 3.2.2把项目锁定到指定版本。

第三,Appium驱动缺失。2.x版本手动装完xcuitest驱动后,启动Appium还会遇到Could not load the XCUITest driver之类的错,这时候检查appium driver list看状态是否正常,必要时appium driver uninstall xcuitest再重装。

四个问题里我最想强调的还是版本一致。团队里如果有人安装的Cucumber是4.x,有人是5.x,很快就会出现“我这里能跑你那里跑不了”的末日场景。因此Gemfile.lock必须提交到版本仓库,任何人不允许自行升级依赖版本。

4.2 元素定位与等待策略

iOS自动化里元素定位不通是报错的重灾区。我的定位优先级固定是:accessibility id > id > class chain > xpath。XPath在iOS里能不用就不用,性能差、写法繁琐,而且XCUITest的XPath支持远不如Web端成熟,稍微复杂的路径表达式经常定位不到。

等待策略也是大坑。默认情况下Appium的操作是立即执行的,页面动画没结束你就去点按钮,大概率点空。我的做法是全局设置隐式等待:

driver.manage.timeouts.implicit_wait = 15

然后对关键元素增加显式等待,比如登录成功后首页标题的出现:

wait = Selenium::WebDriver::Wait.new(timeout: 15) wait.until { @login_page.home_page.displayed? }

隐式等待管“元素还没出现”,显式等待管“元素出现了但条件没满足”,两条配合起来,脚本稳定性才有保证。我观察过一个现象,同样是登录功能,不加重试机制的时候偶发失败率大概在15%到20%,加完显式等待后能压到1%以内。

4.3 稳定性优化三板斧

第一板斧,操作前主动等元素可点击。Appium的displayed?只能说明元素存在,不代表可交互。我后来封装了一个tap_when_clickable方法,利用wait_for_element不断重试直到元素可点击再操作,替换掉所有裸click。

第二板斧,失败重跑机制。Cucumber的retry插件可以给失败场景自动重跑,我是在cucumber.yml里配了--retry 2。但注意重跑只能解决偶发问题,如果同一个场景重跑三次还挂,八成是真实业务bug或者定位方式本身有问题,这时候必须人工介入。

第三板斧,用例间状态隔离。实际上我前面提到的“每个场景重启驱动”就能避免大部分状态串扰。后来为了提速,我改成只在Before里判断驱动是否存活,存活就复用,失败场景的驱动则强制重启,跑下来速度提升了约三成,稳定性也没有明显回退。

我在实际项目里的体会是,iOS自动化测试最困难的部分从来不是写脚本,而是搭环境和对抗不确定性。Cucumber解决的是“用例可读、可维护、跨角色沟通”的问题,但底层驱动和元素操作依然是纯技术活,该蹲的坑一个也不能少。把环境固定好、等待策略做好、元素定位方式选对,剩下的事情就交给脚本,你的精力可以花在更值得的业务分析上。

最后再分享一个小技巧:如果你刚开始接触这套方案,不要一上来就追求覆盖全量业务。挑一条最核心的链路,比如登录到首页,从环境搭建到用例跑通,完成第一个闭环。这个闭环跑通之后,剩下的事情都是重复劳动——加页面、加步骤、加场景,架构不需要再动。我当年就是这么起步的,先跑通登录,然后一周内把核心流程全部补完,维持住了对这套方案的信心。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进