在测试圈里经常有人问我一个问题:Java技术栈做自动化测试,到底该选Selenium还是Playwright?我的回答通常是,如果你们项目是Java写的,又刚准备把UI自动化补起来,Playwright的Java版本值得认真考虑。这个系列文章我打算从最不起眼、但实际最容易翻车的表单控件开始写,第一篇就先说单选按钮和多选按钮。
这两个控件在页面上看着就是一个小圆点、一个小方框,好像没什么技术含量。但真的上手写自动化用例,你会发现一堆问题:元素明明能定位到,点击就是不生效;用Selenium点过去了,断言又对不上;页面里有隐藏的input,直接用click()还会报元素不可交互。我这次用Java+Playwright把radio和checkbox从头到尾过一遍,包含环境搭建、定位方式、勾选逻辑、断言技巧和踩坑记录,希望能帮你少走弯路。
1. 为什么单选和多选按钮值得单独拿出来讲
1.1 表单里的radio和checkbox:看着简单,翻车点多
先讲个实际的场景。很多初学自动化的人都会拿注册页面练手,性别选择、兴趣爱好、用户协议,这些几乎全是radio和checkbox。一上手就发现,这俩控件在HTML里长这样:一个input标签,type是radio或者checkbox,外面再套个label。真正的视觉样式很多是CSS画出来的,有些甚至把input藏起来,只留一个背景图标。
这种设计给自动化测试带来的麻烦是双重的。第一,定位困难,你看到的那个可点击区域往往不是input本身,而是label或者某个span。第二,交互语义特殊,radio靠name属性分组,同一组里只能选一个,选多了自动互斥;checkbox则是独立的开和关,可以批量勾选也可以取消。
如果你只是机械地写driver.findElement(By.id("xxx")).click(),大概率会碰到两类问题:要么点击事件没有触发到input节点上,要么因为元素被其他节点遮挡,Selenium直接抛ElementClickInterceptedException。Playwright这边处理得就聪明很多,它引入了“可操作性检查”,点击之前会确认元素可见、稳定、可交互、可编辑,不满足就自动等待重试。这也是我推荐用它代替Selenium做表单类控件操作的最直接原因。
1.2 Playwright的交互模型,和Selenium差在哪里
Selenium的WebDriver模型走的是“命令下发”路线,你让浏览器做什么,它就发一条HTTP指令过去,浏览器照做。问题在于,浏览器里元素的状态是动态变动的,你以为点到了,实际上那个时刻元素还在transition里,位置还没稳定,或者按钮正处于disabled状态。所以Selenium时代大家都养成了一个习惯:写sleep,先睡一秒再说。
Playwright走的是另一条路,它内置了自动等待机制。调用click()、check()这类动作时,Playwright会先检查元素是否已经满足可操作条件,不满足就一直等,直到超时。这个机制不是靠穷举轮询硬试的,而是基于浏览器底层事件和DOM状态变化来驱动的,所以写出来的脚本非常干净,几乎没有显式sleep。对于表单控件来说,这个优势尤其明显,因为你不需要在点击radio之前反复确认它是不是真的加载好了。
生活里做个类比的话,Selenium像是在打固定电话,拨号之后不知道对方在不在,只能干等;Playwright则像微信通话,接通后对方在线不在线、正在输入还是已经离开,状态都是双向同步的。理解了这一层,后面再用它的API,很多迷惑行为就都能解释了。
2. 把环境跑起来:Java+Playwright最小工程
2.1 Maven依赖引入与浏览器内核下载
想要在Java项目里用Playwright,第一步是在pom.xml里加入依赖。我用的是1.47.0版本,自己项目里锁定这个版本,升级之前会先在测试环境验证。
<dependency> <groupId>io.playwright</groupId> <artifactId>playwright</artifactId> <version>1.47.0</version> </dependency>依赖加好之后,Playwright并不会自动帮你下载浏览器内核。它需要单独执行一个安装命令,把Chromium或者Firefox的二进制下载到本地缓存目录。Maven工程中可以直接用exec插件触发:
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install"如果想装完顺手把系统依赖也补上,可以用install --with-deps。这个命令在Linux CI环境里特别常用,Windows和macOS本地跑一般不需要。
这里有个常见问题值得提前说:如果你在公司内网或者下载受限的网络环境里执行install失败,不要急着放弃。Playwright支持通过环境变量PLAYWRIGHT_DOWNLOAD_HOST指定下载源,可以指向内部镜像或者本地的离线包目录。我司目前就是先把浏览器包放到内网对象存储,再在CI脚本里设置这个环境变量,下载速度和稳定性都有明显改善。如果不想用这种方式,也可以手动下载对应版本的浏览器包,放到用户目录的~/.cache/ms-playwright下,再执行检查命令即可。
2.2 一个能直接跑通的浏览器启动模板
环境装好之后,建议先写一个最小模板,验证整个链路能跑通。Java版的Playwright核心接口有三个层级,分别是Playwright入口、Browser浏览器实例和BrowserContext上下文,页面Page则挂在上下文下面。
import com.microsoft.playwright.*; public class DemoStart { public static void main(String[] args) { try (Playwright playwright = Playwright.create()) { Browser browser = playwright.chromium().launch( new BrowserType.LaunchOptions().setHeadless(false) ); BrowserContext context = browser.newContext(); Page page = context.newPage(); page.navigate("https://example.com"); System.out.println("页面标题: " + page.title()); context.close(); browser.close(); } } }一个小知识点:setHeadless(false)会让你本地调试时看到真实的浏览器窗口,方便观察元素状态。而在CI里跑的时候,建议改成默认的无头模式,省资源也更快。BrowserContext这一步很多人会忽略,它的隔离能力其实非常实用,每个context相当于一个独立的浏览器会话,cookie、localStorage、缓存都是隔离的,你可以在多个context里并行跑不同测试数据,互不干扰。
2.3 代码跑起来了,怎么确认它真的在用Playwright
很多第一次接触Playwright的人会有个疑问:我就是起了个浏览器打开了页面,跟用Selenium好像没啥区别嘛。其实区别从这一行代码开始显现,看下面这个例子:
page.locator("input[name='gender'][value='male']").check();这一行代码放到Selenium里,你可能需要先findElement,再确保它是可见的,再click,再手动isSelected()断言。Playwright的check()把这一切合并成了一个意图明确的操作。它自己内部完成了可见性判断、滚动到视口、等待可交互、点击或设置checked属性这一整套流程。这就是我后文展开的核心,理解了这套交互模型,才算真正开始用Playwright。
3. 单选按钮:从“能点”到“点得对”
3.1 页面里单选按钮的三种形态
做测试久了会发现,同一个页面上不同开发写的radio,实现方式能差出三个时代。老的网站喜欢用原生input,直接加name和value属性。稍微现代一点的页面用label包住input,label的for属性关联到input的id,这样用户点文字也能选中。
<input type="radio" name="gender" id="male" value="male"> <label for="male">男</label>进入框架时代后,很多组件库开始做自定义渲染,真正的input可能是隐藏的,视觉上是一个div模拟的小圆点加一个span文字。面对这种页面,靠样式定位很容易翻车,因为点击div并不会自动更新表单状态,必须触发底层的input事件才行。
Playwright的解决办法比较优雅:不要只盯着视觉元素,而是通过行为定位。radio的语义角色就是radio,不管外层怎么包装,只要底层的input具备radio类型且可以被语义识别,就能用getByRole来定位。我们做自动化时也建议优先用语义和结构定位,而不是类名样式,这是长期维护最省心的方式。
3.2 用Locator精确定位,并完成勾选
针对单选按钮,最稳妥的一组定位方式是这样的。如果有明确的id,用id定位最直白;如果有多组radio,用name加value组合定位,精准命中某一个选项。
Locator maleRadio = page.locator("input[name='gender'][value='male']"); maleRadio.check(); Locator femaleRadio = page.getByLabel("女"); femaleRadio.check();这里我提一下为什么推荐check()而不是click()。对于radio来说,click()会产生完整的鼠标事件序列,mousedown、mouseup、click,这确实能选中元素。但check()语义更聚焦,它明确表达“我要让这个radio处于选中状态”,而且如果元素已经选中了,check()会直接跳过,不再重复点击。这个幂等特性在写循环和参数化用例时特别有用。
如果页面里radio的id不固定,但文字固定,用getByLabel也是一种常见方案,它会把label的关联关系解析出来,定位到真正的input上。实际项目里我经常混用,优先name+value,次选getByText相关的方案。
3.3 勾选后的状态断言和反选处理
单选按钮的核心业务逻辑,是同一组内互斥。你从男的切到女的,男的就会自动变成未选中。自动化用例里验证的就是这件事。
PlaywrightAssertions.assertThat(page.locator("input[name='gender'][value='male']")).isChecked(); PlaywrightAssertions.assertThat(page.locator("input[name='gender'][value='female']")).not().isChecked();这里有个值得说的细节:Playwright的断言默认是会自动重试的,不像JUnit那种硬比较。如果页面里radio的选中态是异步更新的,比如点击后发起请求,服务端返回才更新状态,普通的assertEquals会立刻读完立刻失败,而assertThat().isChecked()会等到状态稳定再判断,默认等待时间可以配置。极端情况下,如果你想明确验证radio没有被选中,用not().isChecked()也很直观。
涉及反选操作时要注意,radio一旦选中就不能靠同一个元素再次点击取消,这是浏览器原生行为决定的。自动化脚本不需要强行去复现这种物理不可能的操作,而是应该去验证“同组内选中另一个,原来的自动取消”,这个粒度才是业务能接受的。
4. 多选按钮:勾选、取消、全选与批量断言
4.1 check()和uncheck():成对使用,别搞混
checkbox和radio最大的区别就是“可逆”。一个checkbox可以反复勾选和取消,所以Playwright为它专门提供了check()和uncheck()两个方法。用法不复杂:
Locator agree = page.locator("input[name='agreement']"); agree.check(); // 业务做了某些操作后 agree.uncheck();这里要提醒一个容易踩的坑:uncheck()这个方法在radio上是不存在的,或者说即使调用了也不会产生业务上的取消效果。有些同事刚上手时会把checkbox的操作封装通用函数,radio和checkbox混在一起传参,结果调试了半天才发现方法选择错误。建议在封装公共方法时先判断input的type,再决定走check还是uncheck分支。
另外,checkbox在业务上经常有一种“半选”状态,就是echarts这类组件树里的indeterminate状态。这种状态本质上是视觉半选,DOM层面它的checked属性还是false。Playwright的isChecked()判断的就是原生checked属性,遇到半选状态时不能直接拿它做结论。我目前的处理方式是,如果业务需要验证半选,就额外检查对应的类名或者aria-selected属性,这个属于定制化断言,框架给不了通用解。
4.2 全选和部分勾选:批量操作的落地写法
自动化里最常见的checkbox需求是两个:一个是全选,一个是按组勾选。全选的写法很直白:
Locator checkboxes = page.locator("input[type='checkbox']"); List<Locator> allBoxes = checkboxes.all(); for (Locator box : allBoxes) { box.check(); }这里的all()方法会返回当前页面匹配到的全部元素,然后逐个执行check。前面提到的幂等性在这里会发挥重要作用,即使某些checkbox已经处于选中状态,再调check()也不会报错或者产生额外的点击事件。这个特性比Selenium里那种无脑循环click()安全很多,不容易因为重复点击导致页面状态错乱。
部分勾选的场景也不复杂。我们有个消息通知设置页,里面有十几类通知渠道,测试时要勾选其中特定的几类。我的写法是先拿到所有checkbox的名称和值,建立映射关系,然后按测试数据来勾选。如果测试数据来自外部Excel或者JSON,参数化的价值就会体现出来,用例本身可以做到零改动,换数据就能跑新场景。
4.3 断言多选状态时的常见姿势
多选断言比单选稍微麻烦一点,因为需要验证的往往不止一个元素,而是一批元素的组合状态。比如一个表单里既要求性别选择了、又要求用户协议勾了、还要至少选了两个兴趣爱好。
我习惯的做法是组合断言:
PlaywrightAssertions.assertThat(page.locator("input[name='gender'][value='female']")).isChecked(); PlaywrightAssertions.assertThat(page.locator("input[name='agreement']")).isChecked();如果断言数量比较多,可以再配合一个统计逻辑:统计当前页面被勾选的checkbox数量,然后和预期值比较。这个场景用手写循环加计数器就行,不需要额外引入断言库,读起来反而更清晰。
还有一点是断言时机。checkbox的勾选状态会影响到按钮的可用性,比如用户不勾选协议,提交按钮永远是disabled。这种联动场景在测试用例里非常常见。我的建议是,验证时不要只依赖某一个快照,而是通过多次动作迭加验证,比如先不勾选,断言提交按钮不可用;再勾选,断言按钮可用。这样整个用例的语义是完整的,也更接近真实用户的操作路径。
5. 实操实录:这些坑我基本都踩过
5.1 点了按钮,断言还是失败
这是一个被问得最多的问题:脚本执行时明明看到那个圆点已经被选中了,但断言还是失败。排查下来的原因通常有三类。第一类,页面里有重复的checkbox或radio,定位器匹配到了隐藏的那个,点击的是视觉元素,但更新的是另一个DOM节点。第二类,点击后状态是异步变化的,需要一点时间让前端框架把checked属性同步过来。第三类,元素被label包裹,Playwright的check生效了,但断言用错了节点。
解决办法其实就一句话:让定位器精确到不能再精确。排查时我一般先在Playwright的调试模式里跑一遍,把实际匹配到的元素数量打出来,再配合nth()或者filter()过滤到唯一。异步更新这个问题,最好借助断言本身的重试机制去解决,而不是手动加sleep,前者在页面响应变慢时依然能稳定通过,后者只会让用例跑得更慢。
5.2 hidden input点不到,是改页面还是该换定位法
遇到开发把input隐藏起来、只展示一个自绘圆点的情况,很多人第一时间想到用setChecked(true, new Locator.SetCheckedOptions().setForce(true))强行设置状态。这个API确实管用,但我不建议把它当默认解法。
force=true会绕过可操作性检查,直接把checked属性写进去。这样做的副作用是,它没有走真实的用户操作路径,一些依赖于click事件来联动其他逻辑的页面,比如点击后需要弹提示或者记录埋点,就不会触发。用force相当于跳过了系统校验,你要确保自己真的理解页面逻辑,否则容易做出“测试通过但功能实际已经坏掉”的虚假结论。
我一般先用getByLabel看看能不能通过关联label定位,这样点击是真实发到input上的。如果页面结构实在没有label关联,再考虑用page.evalOnSelector直接执行原生click或者dispatchEvent,这样也能模拟出完整事件链。
5.3 iframe里的radio和checkbox,frameLocator解君愁
现在很多后台管理系统都流行iframe嵌入。如果在iframe里操作radio和checkbox,Playwright也有专门方案。直接在page对象上定位是不行的,你永远定位不到iframe内部的元素,必须先切进frame。
Locator agreeInFrame = page.frameLocator("#agreementFrame").locator("input[type='checkbox']"); agreeInFrame.check();注意frameLocator返回的对象在Java API里也是一种Locator类型,后续的check、断言、等待行为都和普通Locator完全一致,不需要额外处理跨frame状态。这让iframes操作成本变得很低。相比之下,Selenium里要来回切换driver的焦点,Java代码里动不动就要写switchTo().frame(),维护起来又乱又容易漏掉切回逻辑。这也是我目前在新项目里坚持用Playwright的一个重要原因。
5.4 Playwright安装阶段的问题汇总
虽然安装问题不是写用例阶段的事,但它卡住了很多人入门的第一步,这里把高频问题一起列出来。如果你执行mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install"失败,先别慌。
第一个常见问题是下载超时。Playwright默认从官方的CDN拉取浏览器包,在公司网络环境下很容易失败。解决办法就是刚才提到的,设置PLAYWRIGHT_DOWNLOAD_HOST环境变量指向内部镜像。第二个常见问题是权限不足,Linux服务器上安装时需要写缓存目录,如果用的是Jenkins这类CI账号,经常因为目录权限装到一半退出。可以手动给用户目录授权,或者设置PLAYWRIGHT_BROWSERS_PATH到项目团队有写权限的目标路径。
第三个问题是版本不匹配。有时候pom里依赖的Playwright版本和你本地缓存的浏览器内核版本不一致,启动时会直接报连接错误。处理方式也很简单,清掉~/.cache/ms-playwright下对应的旧目录重新install即可。这里我补一句个人经验:建议把Playwright依赖版本和浏览器安装版本一起锁成一个固定组合,在pom里声明清楚,团队协作时能减少很多莫名其妙的报错。
6. 工程化建议:把控件操作封装成Page Object
6.1 为什么POM在Java+Playwright里更顺手
前面分享了这么多细节,但要写成一个能长期维护的自动化测试体系,还是得往工程化方向上走。很多Java工程师对Page Object Model都不陌生,它的核心思想是给每个页面建立一个类,把页面上元素和操作封装成方法,测试用例只调用方法、不直接接触定位器。
Playwright的Java API和POM配合得非常好,因为Locator可以声明为类的字段,不必在每次操作时都重新查询。而且Playwright的Locator是“惰性定位”的,它不会在创建时就抓取DOM节点,而是每次执行动作时再去解析。这样即使页面发生了跳转或者DOM刷新,同一个Locator实例依然可以正常工作,不会像Selenium的WebElement那样出现stale element异常。
这对POM模式是很友好的,你不用在每次页面切换后重新findElement,写出来的页面类更简洁也更稳定。页面级别的方法返回void或者断言结果,测试层只关心业务步骤,维护成本基本都集中在了页面类里。
6.2 一个注册页面的POM示例
拿一个典型的注册页面举例,包含性别单选、兴趣多选、协议勾选三类控件。定义一个RegisterPage类,把定位和操作都封装起来。
public class RegisterPage { private final Page page; private final Locator genderMale; private final Locator genderFemale; private final Locator hobbyBasketball; private final Locator hobbyFootball; private final Locator agreementCheckbox; public RegisterPage(Page page) { this.page = page; this.genderMale = page.locator("input[name='gender'][value='male']"); this.genderFemale = page.locator("input[name='gender'][value='female']"); this.hobbyBasketball = page.locator("input[name='hobby'][value='basketball']"); this.hobbyFootball = page.locator("input[name='hobby'][value='football']"); this.agreementCheckbox = page.locator("input[name='agreement']"); } public void selectGenderMale() { genderMale.check(); } public void selectGenderFemale() { genderFemale.check(); } public void selectHobby(String hobby) { page.locator("input[name='hobby'][value='" + hobby + "']").check(); } public void checkAllHobbies() { page.locator("input[name='hobby']").all().forEach(Locator::check); } public void agreeToTerms() { agreementCheckbox.check(); } public boolean isMaleSelected() { return genderMale.isChecked(); } public boolean isAgreementChecked() { return agreementCheckbox.isChecked(); } }测试用例调用起来就非常直观了:
RegisterPage registerPage = new RegisterPage(page); registerPage.selectGenderFemale(); registerPage.checkAllHobbies(); registerPage.agreeToTerms(); PlaywrightAssertions.assertThat(registerPage.isMaleSelected()).not().isChecked();这里我用了一个selectHobby(String hobby)方法,把兴趣爱好选项参数化,这样测试数据可以来源于外部配置。跑不同的组合用例时,只要改传入的hobby值就行,不用为每个选项单独写一个方法。
6.3 什么时候才需要自己写等待
虽然前面一直在强调Playwright的自动等待很方便,但工程里有些场景还是需要手动介入。比如页面跳转后要等某个控件出来,这时候我习惯用page.waitForSelector()显式等待,并配合timeout控制超时时长。还有一种情况是点击checkbox后,触发了一个接口请求,需要等接口返回后再进行后续操作,这个用Playwright的page.waitForResponse()更精准,比固定sleep可靠得多。
写自动等待时要遵循一个原则:尽量避免毫无依据的固定Thread.sleep()。它浪费时间,而且在不同网络环境下结果不稳定。真要等待,就等一个明确的条件,元素可见、接口响应、URL变化、文本出现都可以,Playwright把这些条件都封装好了,不用白不用。
最后再分享一个实际项目里的细节。我们有个老系统,checkbox的状态是由后端回传的,点击之后前端要等接口返回才会刷新为选中态。一开始同事写用例时习惯在click后加个sleep 3000,用例又慢又不稳定。后来我改成了click后直接断言页面上的提交按钮变成可点击状态,再用这个断言作为后续操作的同步点。这样不仅去掉了sleep,整个用例的语义还更贴近业务逻辑,执行速度也快了很多。遇到类似场景的读者,我强烈建议你试试这个思路:把业务联动关系当作同步信号,让用例的每一步都像真实用户操作一样自然地衔接。