如果你这几年一直在用 Selenium 写 UI 测试,应该会越来越觉得别扭:全量用例跑一趟,一半时间在等 sleep;换个浏览器版本,WebDriver 对不上就罢工;想定位一个动态渲染的元素,XPath 写出来自己都嫌弃。我不是说 Selenium 不行,它确实培养了一整代测试开发工程师,在 Web 自动化领域当了十几年事实标准。但 Playwright 出现之后,我越来越倾向于认为:这不只是换了个工具,而是一次 UI 测试工具层面的范式转移——从"浏览器外部操作"到"浏览器内部协作"。
这篇文章我不打算写成一堂工具使用课,而是想以一个踩过 Selenium 各种坑、又用 Playwright 重构过多个项目的人的身份,聊聊这两个工具背后的设计逻辑差异,为什么 Playwright 能解决 Selenium 时代那些让人抓狂的问题,以及你从旧体系迁移过来时最需要关注哪些细节。
1. 为什么 Selenium 统治了那么久,却被称为"旧范式"
1.1 WebDriver 协议:当年的功臣,今天的瓶颈
聊范式转移之前,先得弄清楚 Selenium 当初是怎么工作的。Selenium 2.0 之后的核心是 WebDriver 协议,说白了就是一套基于 HTTP 的接口约定。你的测试代码通过 HTTP 请求访问本地或远程的 WebDriver 服务,再由这个服务把命令转成浏览器能理解的指令。每一步操作,都是标准的"请求—响应"模式。
这套架构在那个年代非常先进:它用一套统一接口屏蔽了不同浏览器的差异,Firefox 有 geckodriver,Chrome 有 chromedriver,IE 还有一套让老测试工程师头皮发麻的 driver。你只要写一套测试代码,理论上就能跑在不同浏览器上。这是 Selenium 能统治十多年的根本原因。
但这个架构的瓶颈也很明显:通信成本高、信息单向、反馈滞后。每一条命令都要走一次 HTTP 来回,浏览器内部的渲染进度、网络状态、控制台日志,Selenium 并不能主动感知。最典型的一个痛点就是等待——页面到底加载完没有,Selenium 自己不知道,只能靠测试代码里的time.sleep或者显式等待去反复探测。你可以把它理解成一个站在门外指挥的人,每走一步都要敲门问一次,屋子里什么情况他全靠猜。
1.2 等待、定位、多分支:Selenium 三大痛点实录
我在 Selenium 时代写过不少"能跑但丑得没法看"的用例,核心痛点逃不过这三大类:
第一是等待。理论上 Selenium 有隐式等待和显式等待,但实际用起来非常考验经验。隐式等待一旦设置,会对后续所有 find 操作生效,包括那些根本不该等那么久的查询;显式等待用起来又很啰嗦,必须写WebDriverWait配合expected_conditions,代码行数瞬间翻倍。更坑的是两者混用的时候,等待时长会被叠加放大,一个用例慢个十几秒是常事。到了后期,团队里几乎形成了"等不到就加 sleep"的风气,测试代码里全是time.sleep(3),跑起来奇慢无比。
第二是元素定位。Selenium 里的find_element返回的是一个"快照式"引用,只要页面发生了重排、刷新,这个引用就可能失效,你得重新查找。定位方式虽然全面,但真正好用的不多:XPath 写长了脆得一碰就碎,CSS 选择器对动态 class 无能为力,By.LINK_TEXT一遇到国际化就炸。最痛苦的还是 iframe 嵌套场景,Selenium 要求你用switch_to.frame切进切出,一旦嵌套层级深了,代码里全是切换逻辑,跟业务毫无关系。
第三是浏览器上下文管理。多标签页、多窗口、弹窗、权限提示,在 Selenium 里每一项都是"状态管理"问题。你要手动去window_handles列表里定位新窗口,切过去用完还要切回来;遇到 alert 弹窗,还得用switch_to.alert处理。这些动作本质上都是在给浏览器当"秘书",帮它协调内部状态。可问题是,浏览器本来就是多页面多上下文的状态集合体,测试框架不应该逼着用户去管理这些底层细节。
2. Playwright 到底改变了什么:三个关键词
2.1 CDP:从浏览器外部到浏览器内部
Playwright 的设计思路是反着来的——它不再通过一个外部 HTTP 接口"发号施令",而是尽可能"钻到浏览器里面"去工作。Chromium 系浏览器本身提供了 Chrome DevTools Protocol,也就是 CDP 协议,Playwright 直接跟这个协议打交道,相当于坐在浏览器驾驶室里操作,而不是站在车外喊话。
这不是一个简单的协议替换,它带来了一堆连锁红利。因为 CDP 本身能感知和干预浏览器内部几乎所有事件:网络请求、DOM 变化、JavaScript 执行、控制台日志、性能指标,甚至内存快照。所以 Playwright 从底子上就有能力做"自动等待""网络拦截""追踪录制"这些操作,而不需要像 Selenium 那样靠轮询去猜。
这里要澄清一个常见的误解:Playwright 并不只支持 Chromium。它也支持 Firefox 和 WebKit,对每个浏览器都封装了对应的驱动机制。但在日常使用中,绝大多数团队都是优先跑 Chromium,然后定期补跑其他内核,因为速度更快、问题也更容易在本地复现。
2.2 自动等待:不用再写 sleep 和隐式等待
Playwright 最让我舒服的一点,就是默认的自动等待机制。你调用click、fill、check这类操作时,Playwright 会先做一套"可操作性检查":元素是否可见、是否稳定(不在抖动)、是否已启用、是否可编辑、是否接收事件。如果条件不满足,它会以轮询方式反复检查,超时后才抛异常。
这意味着什么?意味着我写代码的时候,基本不需要写显式等待,更不用写 sleep。页面跳转有page.goto内部的等待,元素出现有 locator 操作的自动等待,请求完成有wait_for_load_state。这套机制把"时序问题"从测试代码里彻底拿掉了,你只需要描述做什么,不需要关心什么时候能做。
我第一次用 Playwright 时,拿一个老项目里的 200 条 Selenium 用例做对照实验:同样的场景,Selenium 版本平均每条用例跑 45 秒,Playwright 版本平均 12 秒。这 33 秒的差距,绝大部分就是省在了等待上。少了一堆轮询和 sleep,用例反而更稳了,因为自动等待是基于真实页面状态的,而不是来自一个拍脑袋的固定延时。
2.3 Locator:一套选择器体系解决定位难题
传统 Selenium 的定位方式可以概括为"一次性查找",而 Playwright 给你的是一个持续有效的"定位器模型"。用官方术语叫 Locator。这个对象本身是懒加载的,它不立刻去页面里找元素,而是在每次操作时重新查找。这带来的好处非常直接:页面重渲染了、元素位置变了,你的定位器依然有效,不用重新查找。
Locator 的选择器体系也比 Selenium 灵活得多。除了支持 CSS、XPath,还内置了文本选择器、角色选择器、测试 ID 选择器。比如"点击页面上唯一的'提交'按钮",在 Selenium 里你可能要写一长串 XPath;在 Playwright 里就是一句page.get_by_role("button", name="提交"),连按钮类型和可见文本一起约束了,语义化程度高到像是写给人类看的。
我再举个例子对比一下。Selenium 的写法:
from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC driver = webdriver.Chrome() driver.get("https://example.com") try: button = WebDriverWait(driver, 10).until( EC.element_to_be_clickable((By.XPATH, "//button[contains(text(),'提交')]")) ) button.click() finally: driver.quit()Playwright 的写法:
from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page() page.goto("https://example.com") page.get_by_role("button", name="提交").click() browser.close()后者没有显式的等待,没有冗长的选择器,没有手动的驱动管理。自动等待帮我们把"什么时候能点"这个问题解决了,locator 帮我们把"元素怎么描述"这个问题解决了。这就是范式转移最直观的体现——从关心"怎么做"到关心"做什么"。
3. 动手玩转 Playwright:从安装到写第一段测试
3.1 安装与环境准备
理论说再多,不如跑起来实战一次。我先讲 Python 生态下的安装方式,因为这边用 Python 做测试的人最多。
第一步是装库:
pip install playwright第二步是安装浏览器内核:
python -m playwright install chromium这里有一点要特别提醒:如果你只是pip install playwright而没有执行第二步,运行时往往会报错,提示找不到浏览器可执行文件。很多新手在 Selenium 时代习惯了"装个 pip 包再下载一个 chromedriver",到了 Playwright 这里思路要换一下——浏览器内核不是去 Chrome 官网手动下的,而是通过 Playwright 的命令统一管理的。
如果你在 Windows 的 PowerShell 里执行playwright命令,可能会看到一条报错:playwright : 无法将“playwright”项识别为 cmdlet、函数、脚本文件或可运行程序。这种情况基本是 Python 的 Scripts 目录没有加入 PATH,解决办法有两个:一是执行的时候改用python -m playwright,二是手动把C:\Python安装目录\Scripts加到系统 PATH 里。Node.js 生态同理,遇到npx playwright找不到,就检查一下 npm 的全局 bin 路径。
浏览器下载慢是个高频问题。如果在公司网络环境下,官方 CDN 经常连不上,这时候可以设置环境变量PLAYWRIGHT_DOWNLOAD_HOST,指向国内可访问的镜像地址,这个做法和 pip 换镜像源的逻辑一模一样。要注意的是,这个环境变量必须在执行install命令之前就配好,不然进程一启动它就不知道去哪下载了。
3.2 用 Codegen 录制一个真实场景
Playwright 自带一个录制工具 Codegen,这是在 Selenium 时代没有的体验。你在命令行里跑一句:
python -m playwright codegen它会弹出一个浏览器窗口,同时打开一个代码生成面板。你在浏览器里点击、输入、跳转,代码面板里会同步生成对应的 Playwright 脚本。这个工具用来快速搭建用例骨架特别好用,尤其是面对一个你完全不熟悉的被测系统时,录制一遍再人工整理,比对着 DOM 元素写定位要快得多。
我拿一个典型的购物车流程举例。假设要测试一个电商购物页面,流程是:打开商品详情页,点击“加入购物车”,进入购物车页,断言购物车里出现了这个商品。
用 Codegen 操作一遍,生成的代码大概长这样:
from playwright.sync_api import sync_playwright def run(): with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() page.goto("https://shop.example.com/products/123") page.get_by_role("button", name="加入购物车").click() page.get_by_role("link", name="购物车").click() page.get_by_text("无线蓝牙耳机").click() browser.close()这个脚本能跑,但只是骨架。我一般会再补三件事:加断言、加定位优化、加错误提示。比如把最后一步改成断言写法:
from playwright.sync_api import expect expect(page.get_by_text("无线蓝牙耳机")).to_be_visible()这样测试才真正有意义——它不只是走通流程,而是在关键业务节点上做校验。Codegen 的价值是帮你省掉前期摸索 DOM 结构的时间,但它生成的选择器不一定都适合长期维护,比如有些自动生成的选择器会很长,或者依赖了不稳定的 text 内容。录制脚本之后的"人工清洗"才是真正考验测试设计能力的地方。
3.3 自动等待条件下的条件判断与断言
Playwright 的断言库expect和传统断言不太一样,它自带重试机制。你写expect(locator).to_be_visible()时,它不会只判断一次,而是默认在超时时间内反复检查。这跟 Selenium 里用assert element.is_displayed()是本质区别——后者是一次性判断,页面稍微晚渲染 0.5 秒,用例就挂了。
我推荐的做法是,在团队里约定一套断言规范:凡是涉及页面渲染结果的,一律用expect(...).to_be_visible()或to_have_text(),不要用裸的assert。涉及数值判断的,比如购物车数量,可以先获取文本再转 int 判断,也可以用to_have_value配合输入框场景。这套思路可以把用例的稳定性拉到很高的水位。
还有一个 Selenium 时代实现起来很痛苦、Playwright 里很轻松的能力——网络拦截。比如测试一个"下单失败"的弹窗,你在真实环境里很难构造出恰好失败的条件,但可以用page.route把下单接口的返回内容直接伪造掉:
def mock_order_fail(route): route.fulfill( status=500, content_type="application/json", body='{"error": "服务器内部错误"}' ) page.route("**/api/order", mock_order_fail) page.get_by_role("button", name="提交订单").click() expect(page.get_by_text("下单失败,请稍后重试")).to_be_visible()这相当于给你一个"篡改网络请求"的开关,让异常场景的构造变得极其可控。这种能力在传统 Selenium 体系里基本没法干净实现,除非自己起一个 mock server,再手动把请求地址改过去,麻烦得多。
4. 常见问题与排查技巧实录
4.1 安装与命令找不到的排查思路
我把平时在社区和团队里看到的高频问题整理一下,方便你遇到的时候快速定位。
第一类是命令找不到。前面提到的playwright命令不可用,大概率是 PATH 问题,优先用python -m playwright绕开。Node 环境同理用npx playwright。这里有个细节:如果你在终端里明明安装了 playwright,但 npx 仍然找不到,可以检查一下当前项目的node_modules/.bin是否在 PATH 里,或者直接npm link playwright把命令暴露出来。
第二类是浏览器启动失败。常见原因是系统缺少依赖库,尤其在一些精简版的 Linux 环境上,Chromium 启动时会报一堆libX、libGL缺失的错误。解决办法是在安装浏览器时带上依赖自动安装的参数,比如python -m playwright install --with-deps chromium,它会帮你把系统依赖一并装好。本机 macOS 相对省心,Windows 一般也不会缺库,但如果是在 CI 的容器里跑,这条建议几乎必踩。
第三类是端口冲突或资源占用。Playwright 启动的是一个自己的浏览器实例,默认不跟系统里的 Chrome 共享,所以一般不会有端口占用问题。但如果你用了pytest-playwright插件并且开了并行执行,建议看一下 CPU 和内存是否够用,尤其是跑 WebKit 内核时,内存占用会比 Chromium 高一截。
4.2 元素定位、iframe、多页面等经典痛点
先说说 iframe 处理。Selenium 时代要切进切出,Playwright 直接支持在 iframe 内部定位元素。比如页面里有一个嵌套的支付弹窗 iframe,传统写法要switch_to.frame,现在这样就行:
frame = page.frame_locator("#pay-modal-iframe") frame.get_by_placeholder("请输入手机号").fill("13800000000") frame.get_by_role("button", name="确认支付").click()不用切换上下文,frame_locator返回一个"定位器"对象,操作方法跟普通 locator 一样。嵌套 iframe 也可以一层层往下取,代码依然很清爽。
再聊聊多页面场景。点击一个target="_blank"的链接,会打开一个新的标签页。Selenium 需要切 window handle,Playwright 可以用context.expect_page()来捕获新开的页面:
with page.context.expect_page() as new_page_info: page.get_by_text("查看订单详情").click() new_page = new_page_info.value new_page.wait_for_load_state() expect(new_page).to_have_title("订单详情")这种上下文感知能力,让我在写多标签页用例时几乎不用关心"当前页是哪个",框架自己知道。
还有一个高频坑:点击被遮挡元素。页面底部可能有吸底弹窗、cookie 提示条,把目标按钮挡住了。Selenium 点击时经常抛 "element not interactable",你只能先手动关掉遮挡层。Playwright 里遇到这种情况,优先确认是否为弹窗遮罩,如果是则先关掉;如果只是想快速通过,可以调用locator.scroll_into_view_if_needed()把元素滚动到可视区域,再用普通点击。但注意这可能是"治标不治本",真实用户可能也点不到那个按钮,这是产品交互的问题,测试该报就得报。
4.3 调试工具:Trace Viewer 与调试技巧
以前用 Selenium 调试用例,最痛苦的是错误只看得到一行抽象描述,不知道实际操作到哪一步。Playwright 提供了一个非常实用的追踪工具 Trace Viewer,可以在用例执行时记录每一步的 DOM 快照、截图、网络请求和控制台日志。
启用方式是在 context 层面开启 tracing:
context = browser.new_context() context.tracing.start(screenshots=True, snapshots=True, sources=True) # 执行用例 context.tracing.stop(path="trace.zip")然后命令行执行:
python -m playwright show-trace trace.zip它会打开一个可视化页面,左侧是操作列表,右侧是每个操作时刻的页面截图和 DOM 结构。用例挂了之后,我一般不看堆栈,直接看 trace 里最后一步操作是什么,页面状态长什么样,问题基本一眼就能定位。
再推荐一个调试技巧:失败时自动截图。配合pytest-playwright插件,可以在 conftest.py 里加一段 fixture,用例失败时自动截全屏并保存到 reports 目录。我习惯在截图命名里带上用例名和时间戳,这样一堆失败报告摆在 CI 上时,扫一眼文件名就知道是哪条用例、大概什么时间跑的。
5. 范式转移对测试团队的意义
5.1 测试代码的维护成本变化
从一个团队协作的视角看,Selenium 到 Playwright 的迁移,表面上换的是 API,实际上改的是编写测试的心智模型。Selenium 时代,大家花大量时间在处理隐式等待、切换 iframe、管理窗口句柄这些事上,测试用例读起来像是一堆底层操作的堆砌。而 Playwright 的用例更像一份场景描述:打开什么页面、点击什么按钮、看到什么结果。
维护成本也明显下降。自动等待和 locator 机制让用例变得更加稳定,失败率能降一整个量级。我从多年实践里的观察是:一个用了半年 Playwright 的团队,用例维护工作量大约只有同等规模 Selenium 项目的三分之一。这不是说 Playwright 没有 bug、不会出怪问题,而是它把大量容易出错的环境因素、时序因素接管了,让测试工程师能把精力放在业务场景建模上。
5.2 从录制脚本到 AI 辅助
最近圈子里讨论很多的一个方向,是把 AI 能力接进测试流程。Playwright 社区已经有人在尝试通过 MCP 协议把工具暴露给大模型,让 AI 根据自然语言描述直接生成或修改测试用例。我个人的态度是:工具可以在前期辅助生成骨架,但测试设计、断言策略、数据准备这类涉及业务理解的部分,短期内还是得靠人盯。
AI 辅助最实际的落地场景,是让 AI 读懂报错信息并给出定位建议,或者根据录制操作生成初步用例脚本,再由人来审查。这个流程比从零开始写要高效得多,但也别指望完全自动化——测试用例是需要长期维护的资产,设计不当的用例即使生成出来,跑两天也得重写。
收个尾:一个老测试的心里话
如果你问我,现在新项目到底选 Selenium 还是 Playwright,我的答案很明确:新项目直接用 Playwright,没有太多可犹豫的。它把 Selenium 时代那堆"反人类"的工作方式改得足够彻底,自动等待、locator、网络拦截、trace 调试,每一样都踩在我的痛点上。
但我也想说一句:不必急着把老项目一夜之间全部重写。Selenium 的存量用例如果跑得稳定,先留着,新页面、新模块用 Playwright 写,等积累到一定量级,再逐步把核心链路迁移过来。范式转移从来不是一蹴而就的,工具会升级,思路会迭代,但测试的本质不变——用最可靠的方式,验证真实用户在乎的那些事。我个人在实际操作中体会最深的一点是:选工具只是一层,真正决定用例质量的,还是你对业务的理解和一套稳定的定位策略。工具给你提供了不需要手动等待的环境,但设计出值得等待的用例,永远是测试工程师自己的功课。