
1. 为什么要换掉 Selenium一场测试工具的更替逻辑1.1 Selenium 最让人头疼的三件事先亮个底。我在 Web 自动化测试这条路上走了差不多六年前四年基本都耗在 Selenium 上。不是说 Selenium 不好——它底子扎实、生态庞大、文档全到今天依然是很多团队的标配。但真正把测试规模做起来之后你会被三件事反复折磨。第一是驱动程序。每个浏览器版本升级你必须去下载对应版本的 WebDriver。Chrome 从 115 版本开始虽然出了自动管理驱动的方案但旧项目里手动配置 chromedriver 的场景太常见了。CI 机器上跑着跑着突然报session not created: This version of ChromeDriver only supports Chrome version xxx——这种错误我见过不下二十次每次都得去查版本匹配表。第二是等待策略。Selenium 的三种等待里隐式等待是个全局定时器的思路但它管不了元素的所有状态显式等待写起来又啰嗦每次都要WebDriverWait(driver, 10).until(EC.presence_of_element_located(...))。更麻烦的是即使你等到了元素存在它不一定可见、不一定可点、不一定没有遮挡。于是代码里大量出现time.sleep(3)这种硬等测试跑 800 条用例光睡觉时间就占了三分之一。我见过一个项目因为页面有动画主流程用例里硬编码了 5 秒 sleep页面优化提速之后测试反而变慢还照样挂。第三是并行能力。Selenium Grid 配置复杂就算了同一个浏览器实例里多个 tab 之间状态共享跑用例的时候必须小心翼翼地做隔离。单元测试的互不影响这个基本要求在 Selenium 项目里要实现得靠各种技巧。我 2024 年把团队最大的一个测试套件从 Selenium 迁移到了 Playwright整个切换过程前后花了两周之后半年里的维护成本肉眼可见地降下来了。下面这些思路全部来自那一次真实迁移供大家参考。1.2 Playwright 的架构优势到底在哪Playwright 来自微软核心思路是直接通过浏览器开发者协议和浏览器内核通信而不是像 Selenium 那样经过 WebDriver 的中间层。这个架构差异带来的是降维打击级别的变化。它把 Chromium、Firefox、WebKit 三种内核做成了统一的封装你写一套测试可以指定跑在哪种浏览器里。每种浏览器都是 Playwright 自己下载和维护的浏览器实例不再有版本不匹配的烦恼。安装的时候执行一次playwright install三套内核全部就位在 CI 上也是同样的命令。还有一个容易被低估的点是 Context 隔离。Playwright 里每个浏览器上下文就是一个独立的会话cookie、localStorage、缓存完全隔离。这意味着你用同一个浏览器进程可以并行开几十个互不干扰的测试环境跑完直接销毁上下文。Selenium 时代需要 Grid 多开节点才能实现的并行现在一个进程内就能解决资源占用却小得多。代码生成器也是迁移之后团队使用频率最高的工具。你打开浏览器操作一遍页面它会自动生成对应的 Python/Java/JS 脚本。虽说不建议直接拿生成代码当最终用例但用来探索元素定位方式、快速产出页面对象的初稿效率提升是肉眼可见的。1.3 迁移的真实成本与收益先说大家最关心的迁移成本。我负责的那个 Web 项目用例大概 1200 条页面对象模型已经比较完善。迁移不是逐条重写而是分层处理底层封装全部重写对应每页的 Page Object 花了一周常用流程用例重写最快配置类、数据准备类用例大概花了三天真正费时间的是那些依赖外部数据的场景因为这些用例本身在设计上就比较脆弱。收益方面最直观的是 CI 执行时间。Selenium 时代全量回归跑一次要 52 分钟Playwright 迁移完成后是 19 分钟。这个差距主要来自三点并行执行能力、自动等待机制省掉了大量硬编码 sleep、浏览器启动速度更快。其次是测试稳定性从每天挂十几个用例降到偶尔挂一两个而且挂的原因通常是数据污染而不是脚本问题。如果你问我什么情况不建议迁移项目只有几十条用例、团队完全没有自动化基础、系统是遗留的老 IE 内核应用——Selenium 依然是合理的低频维护方案。如果用例超过两百条、浏览器需要覆盖 Chrome 之外的型号、CI 执行时间已经成为迭代瓶颈那 Playwright 值得你认真考虑。2. 环境搭建与第一个 Playwright 脚本2.1 安装与浏览器驱动管理Playwright 的安装流程在同类工具里算简单的但有几个细节值得提前说清楚。Python 环境下我建议直接装 pytest 插件pip install pytest-playwright这条命令会同时装好 pytest、playwright 和 pytest-playwright 三个包。装完别急着写代码先执行浏览器内核安装playwright install chromium这个地方好多人在国内网络环境下会卡住。如果下载浏览器二进制文件失败可以设置环境变量切换下载镜像这个我们到第 5 章专门讲。你还可以用playwright install --with-deps把 Linux 系统依赖一起装上CI 环境上这一步能省掉很多细节报错。装完后可以用playwright install --list确认安装了哪些内核。有些团队会纠结要不要三套内核全装我的建议是本地开发先只装 ChromiumFirefox 和 WebKit 的兼容性验证丢给 CI 阶段。毕竟三套内核加起来 1GB 多本地磁盘空间不宽裕的话别全上。2.2 编写第一条用例打开页面、定位、断言装好后我们直接写第一条用例。我习惯用 pytest 写测试用例因为 pytest 的断言体系、fixture 机制和参数化能力都是现成的。看下这段代码from playwright.sync_api import sync_playwright def test_bing_search(): with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() page.goto(https://example.com) title page.title() assert Example Domain in title browser.close()这是最小可运行的脚本。但真实项目中我不会这样裸写而是用 pytest-playwright 提供的 fixture 来管理浏览器生命周期。改造一下from playwright.sync_api import Page def test_first_page(page: Page): page.goto(https://example.com) assert page.title() Example Domainpage这个 fixture 是 pytest-playwright 自动注入的它已经帮你完成了浏览器启动、新页面创建、用例结束后关闭这一整套生命周期管理。每个测试函数拿到的是一个独立的浏览器上下文完全隔离。如果让我说说什么是现代Web 自动化的第一个感知差异那就是到这里为止你没有手动设置过任何 driver 路径。浏览器是自己管理的fixture 生命周期是自动的剩下的事专注在业务逻辑上就好。2.3 与 Pytest 集成的基础配置pytest-playwright 开箱即用的配置比大多数人想象的完整。在项目根目录创建一个pytest.ini[pytest] addopts --headed --tracingretain-on-failure --screenshotonly-on-failure --videoretain-on-failure这几行配置的意思是默认有头模式跑、失败时保留 trace 文件、截图和录屏只在失败时留存。执行用例的时候哪怕是全量回归也只会在失败用例的目录里生成调试素材。我强烈建议从第一天就打开 trace 录制排查问题时能省大量时间。你还可以用命令行参数指定浏览器跑法pytest --browserchromium --browserfirefox --browserwebkit这样一次命令就把三套内核全跑一遍。注意--headed是默认配置写完用例调试完成后记得改成--headedFalse或者直接删除没有头模式下 CI 内存占用会小很多。3. 核心 API 拆解定位、等待与断言3.1 定位器Locator体系为什么比 find_element 更好这是 Playwright 使用体验上提升最明显的地方。用过 Selenium 的人都知道find_element_by_xpath定位失败时如果没人检查页面变化用例会一片一片地红。Playwright 的 Locator 设计直接改变了这个局面。Locator 的核心特点是懒加载代码里创建定位器时并不立刻查找元素而是等到你真正操作它时才去节点树里匹配。而且每次操作的时刻都会重新查询所以元素在页面上变了位置或者重新渲染了只要定位条件还能匹配操作就不会失败。推荐使用的优先级大致如下get_by_role、get_by_text、get_by_label、get_by_placeholder、get_by_test_id、CSS 选择器。其中最推荐get_by_role因为它完全模拟屏幕阅读器理解页面的方式对可访问性良好或不良好的页面都能稳定定位。示例# 之前 Selenium 风格的定位 # driver.find_element(By.ID, submit-btn).click() # Playwright 风格 page.get_by_role(button, name提交).click()这里有个关键区别按钮的文案从提交改成确认提交get_by_role(name...)用的是精确匹配改文案会导致用例失败这时候你反而会感谢那个失败提示——它告诉你前端改了东西需要同步更新用例。而不是像 XPath 用contains(text(), 提交)那样页面文案改了就悄悄失效还不报错。当你要处理一组相同类型的元素Locator 还有一个很有价值的filter方法rows page.locator(table tr).filter(has_text错误用户)这个has_text可以把定位范围收窄到特定文本所在的行后面再对行内元素操作就方便了。3.2 自动等待机制Web-first 断言与无 sleep 测试要说 Playwright 最让我回不去的特性自动等待绝对排第一。它内置了可操作性检查每次点击或输入前会自动等元素满足五个条件元素已经附加到 DOM、可见、稳定不持续变动、接收事件不被遮拦、处于启用状态。这些条件不满足就继续等直到超时。这意味着什么你在 Selenium 里写的显式等待、隐式等待、sleep 几乎可以全部删掉。看下面这个场景def test_login(page: Page): page.goto(/login) page.get_by_label(用户名).fill(tester) page.get_by_label(密码).fill(123456) page.get_by_role(button, name登录).click() # 登录后的首页有一个用户信息区Playwright 会自动等它出现 page.get_by_text(欢迎回来tester).wait_for()没有一行 sleep。点击登录按钮后页面跳转、接口返回、DOM 渲染完成Playwright 的wait_for()会自动轮询直到目标元素可见。这就是Web-first的核心思想断言和操作是面向最终状态的而不是面向过程的。只要最终页面呈现了预期结果用例就是稳定的。当然自动等待不是万能的。遇到极少数情况你真的需要等待固定时间比如页面在做长时间动画Playwright 提供了page.wait_for_timeout(1000)。但请务必慎用代码里如果大量出现它说明你的定位器策略有问题应该回去检查等待条件而不是用 sleep 硬抗。3.3 断言系统与 trace 调试断言方面Playwright 自己提供了一套异步断言但 pytest 自带断言就很好使。我的习惯是配合expect使用原因是它自带重试机制断言第一次失败不会立刻抛出异常而是会在超时时间内反复检查直到条件满足或超时。看例子from playwright.sync_api import expect def test_expect_with_retry(page: Page): page.goto(https://example.com) expect(page.locator(h1)).to_be_visible() expect(page).to_have_title(Example Domain)这个expect和 Selenium 的显式等待一样但语法上更简洁。断言失败的信息量也大它会告诉你当前页面的实际状态是什么、你的定位器匹配到了多少个元素、超时时间是多少甚至还会把附近的 DOM 片段截出来。调试方面Playwright 提供的 Trace Viewer 是真正改变了排查效率的功能。在pytest.ini里加--tracingretain-on-failure用例失败后会在测试输出目录生成trace.zip。用浏览器打开它你能完整回放测试过程中的每个操作鼠标移动、点击、输入、网络请求、控制台输出。定位一个失败用例的原因从原来的看半天日志猜变成了直接看回放找错排查时间大概能缩短到原来的三分之一。4. 实战案例完成一个完整的 Web 项目测试套件4.1 项目背景与用例设计思路拿我前段时间做的一个 CMS 后台管理系统举例。这个系统有登录、文章管理、分类管理、用户管理几个模块前端是 React 写的单页应用接口是 RESTful 风格部分接口返回比较慢。在 Selenium 时代这套系统跑全量回归要 40 多分钟其中有大量的time.sleep(2)等着数据加载。迁移到 Playwright 之后我重新梳理了用例设计。核心思路是把测试拆成领域行为而不是页面流程。比如登录这个模块关心的领域行为是正确凭据可以进入后台、错误密码给出提示、被锁定账号无法登录。页面流程才去关心点击哪个按钮、输入哪个输入框。这样用例更稳定也更好维护。另外一个重要的设计原则是测试隔离。每个用例之间不能共享登录态和数据状态。Playwright 的 context 隔离帮了大忙每个测试自动拿到独立的上下文cookie 和 localStorage 不共享所以 A 用例的登录态不会污染 B 用例。还有一个容易被忽略的点测试数据不要写死在代码里。我每个模块的测试数据都放在data/目录下用 JSON 管理并且用名称为每个测试生成独一无二的账号避免相互影响。4.2 登录模块从正确流到错误流的完整实现登录模块是最简单的自动化测试场景但也是写得好不好最见功力的地方。完整实现如下import json from playwright.sync_api import Page, expect with open(data/users.json) as f: users json.load(f) def test_login_success(page: Page): page.goto(/login) page.get_by_label(用户名).fill(users[admin][username]) page.get_by_label(密码).fill(users[admin][password]) page.get_by_role(button, name登录).click() expect(page.get_by_text(欢迎回来admin)).to_be_visible() expect(page.locator(a, has_text退出登录)).to_be_visible() def test_login_wrong_password(page: Page): page.goto(/login) page.get_by_label(用户名).fill(users[admin][username]) page.get_by_label(密码).fill(wrong-password) page.get_by_role(button, name登录).click() expect(page.locator(.error-tip)).to_contain_text(用户名或密码错误)注意这里有两个细节。第一个是expect(...).to_contain_text而不是to_have_text因为错误提示可能包含前缀文案。第二个是登录成功后的断言我同时验证了页面出现欢迎回来文案和退出登录链接这是双保险——防的是登录成功但页面没有正确跳转这种半成功状态。这里再说一下我踩过的坑不要在错误流用例里用等待错误提示出现来代替断言成功。有些系统前端会先发出请求、后返回错误如果你的断言写的是错误提示最终出现慢网络下它确实会出现但用例没有真正验证到登录被拒绝了这个业务结果。所以错误流也要等网络请求结束。Playwright 里可以这样with page.expect_response(lambda r: r.url.endswith(/login) and r.status 400): page.get_by_role(button, name登录).click() expect(page.locator(.error-tip)).to_contain_text(用户名或密码错误)expect_response会阻塞到匹配的响应返回确保后端真正拒绝了请求之后再断言前端提示。4.3 列表页与表单场景筛选、分页、排序列表页是我觉得 Playwright 最能体现自动等待优势的场景。React 列表页一般有数据请求、加载动画、渲染列表三个环节Selenium 时代这段脚本最痛苦。Playwright 的wait_for能顺利等数据加载完成。看一个带筛选条件的用例def test_filter_articles(page: Page): # 先登录 page.goto(/login) page.get_by_label(用户名).fill(admin) page.get_by_label(密码).fill(admin123) page.get_by_role(button, name登录).click() # 进入文章管理页 page.goto(/articles) page.get_by_label(状态筛选).select_option(草稿) page.get_by_role(button, name查询).click() # 断言结果 first_row page.locator(table tbody tr).first expect(first_row.locator(td, has_text草稿)).to_be_visible()这个用例看起来平平无奇但如果你遇到的是一个数据量大的列表分页和排序就更有意思了。分页按钮通常是一个按钮组Playwright 里可以用get_by_role(button, name下一页)定位。分页之后要注意有些前端在数据加载完成前会显示加载中这时你断言表格行数反而是不稳定的。最稳妥的方式是等一个业务的成功标识比如页面顶部出现共 xx 条记录这类文案。表单场景里我特别想提醒一个问题不要只填必填字段就把用例当成完整验证。真实项目的表单往往有联动逻辑。我写过一次非常头疼的用例两个下拉框是联动的选择省份后城市下拉会重新渲染。这时候如果你用select_option直接选择城市Playwright 自动等待会一直等到元素出现再来选择但如果你代码里选择的顺序不对选完省份马上选城市城市列表还是旧的用例就挂。解决方法是选择省份后先等待城市下拉里出现目标选项page.get_by_label(省份).select_option(广东省) # 等城市下拉框里的广州市选项出现 expect(page.get_by_label(城市).locator(option, has_text广州市)).to_be_visible() page.get_by_label(城市).select_option(广州市)这种写法把页面交互的时序关系写清楚了用例可读性和稳定性都提高了。4.4 文件下载、新标签页、弹框与移动端模拟Playwright 对浏览器能力的封装置很全面这几个相对非主流但业务里特别常见的场景处理起来都非常顺。文件下载以前是 Selenium 的痛点要配置浏览器的下载目录、处理下载完成事件Playwright 提供了原生的expect_downloaddef test_download_report(page: Page): page.goto(/reports) with page.expect_download() as download_info: page.get_by_role(button, name导出 PDF).click() download download_info.value download.save_as(fdownloads/{download.suggested_filename})注意save_as之前你可以先调用download.path()读取临时文件内容做校验或者直接save_as到指定目录。这个操作彻底避免了浏览器自动把文件存到下载目录测试代码还得去目录里找文件这种不优雅的做法。新标签页的处理也比 Selenium 直观。Selenium 要window_handles切换Playwright 用context.wait_for_eventdef test_open_new_tab(page: Page, context): page.goto(/dashboard) with context.expect_page() as new_page_info: page.get_by_role(link, name查看完整报表).click() new_page new_page_info.value new_page.wait_for_load_state() expect(new_page).to_have_title(完整报表)弹框方面JavaScript 的alert、confirm、prompt在自动化里曾经很麻烦Playwright 里可以直接监听page.on(dialog, lambda dialog: dialog.accept()) page.get_by_role(button, name删除).click() expect(page.locator(.success-tip)).to_contain_text(删除成功)移动端模拟更是 Playwright 的拿手好戏。它内置了一整套设备描述符模拟 iPhone 或 Android 设备只需要一行配置from playwright.sync_api import devices def test_mobile_experience(): iphone devices[iPhone 13 Pro] context browser.new_context( **iphone, viewport{width: 390, height: 844} ) page context.new_page() page.goto(https://example.com) # 验证移动端菜单按钮 expect(page.locator(.mobile-menu-icon)).to_be_visible()这一块在测试 Web 应用的响应式体验时特别有价值不需要真的拿真机去做回归。5. 常见问题与排查技巧实录5.1 npx playwright install 失败的排查手册这是新团队接入 Playwright 时最常踩的坑。npx playwright install失败的场景我总结了三类网络超时、系统依赖缺失、权限问题。网络超时最常见。如果下载浏览器内核时长时间卡住然后报错可以用环境变量指定镜像。以我自己的经验为例在国内部署的 CI 上通常配置成这样就能跑通PLAYWRIGHT_DOWNLOAD_HOSThttps://npmmirror.com/mirrors/playwright还有一类是 Linux 服务器缺少系统依赖库典型提示是error while loading shared libraries: libnss3.so。装的时候就带上系统依赖安装playwright install --with-deps这句命令需要 root 权限CI 里注意用 sudo最后一种情况Windows 服务器上会报 EPERM: operation not permitted一般是杀毒软件或者权限策略阻止了二进制文件写入。检查一下安装目录的写入权限或者把 Playwright 的缓存目录换个位置 bash # 设置用户级缓存目录 set PLAYWRIGHT_BROWSERS_PATHD:\playwright-browsers5.2 iframe 与 Shadow DOM 的定位策略页面上嵌入 iframe 时Selenium 时代要先switch_to_frame而且还得记住切回来。Playwright 的方案舒服很多它提供了frame_locator可以对 iframe 内部的元素直接定位不需要切换frame page.frame_locator(#payment-iframe) frame.get_by_label(卡号).fill(4111111111111111) frame.get_by_role(button, name确认支付).click()这里最关键的是iframe 里的操作和主文档的自动等待是各自独立的你不用关心什么时候加载完成、什么时候可以切入。frame_locator会自动等待 iframe 可用。Shadow DOM 是另一个曾经让人崩溃的场景。传统的 css 选择器无法穿透 Shadow DOM 边界Playwright 则完全内置了穿透机制# 假设有一个自定义组件的内部结构在 shadow root 里 page.locator(my-component).locator(input[namecode]).fill(A123)注意locator会自动穿透部分 shadow 边界get_by_role这类可访问性定位在 shadow DOM 上也能工作。但如果你遇到的是嵌套的 open shadow root建议还是用get_by_text配合过滤条件别自己走 css 深路径。5.3 测试稳定性优化这些坑我替你踩过了稳定性问题是所有自动化测试的底层矛盾Playwright 能缓解但不会消除。我把最容易踩的坑列一下。测试数据隔离是第一优先级的。我踩过的坑是两个用例都创建了同名的测试数据A 用例先跑创建失败B 用例后跑却意外成功——因为 A 的数据还在那。解决方案是每个用例生成唯一标识的数据用完清理。我把生成唯一 ID 的代码写在 fixture 里import uuid pytest.fixture def unique_username(): return ftest_{uuid.uuid4().hex[:8]}页面里如果有随机变化的元素比如时间戳、计数器断言的时候不要匹配整个文本用to_contain_text或者正则匹配即可。还有一个细节是所有 Selenium 老手都容易忽视的尽量少用page.goto导航多依赖点击链接和路由跳转。原因是 SPA 应用里goto会强制整页刷新绕过了前端路由更容易触发出乎意料的重渲染。我之前有个 React 项目从列表页点击进入详情页很稳定用page.goto(/detail/1)反而偶发白屏。后来统一改成页面内点击跳转稳定性上来了。最后重新提一下 trace 的使用习惯。我现在每次跑完用例不管过没过都习惯性看一眼 trace 文件里两个关键时间点操作发起前的 DOM 状态和操作完成后的 DOM 状态。长期积累下来你能形成一套属于自己的失败模式直觉——看到页面卡在某个请求或者某个元素持续未出现就大概知道是哪类问题。5.4 面对防自动化机制时的测试策略我知道很多同学在准备自动化测试面试时会好奇Playwright 能不能过瑞数这类问题。说点实在的如果一个站点部署了企业级的 bot 管理防护任何自动化工具都很难稳定突破——这不是 Playwright 或 Selenium 的问题而是这些工具的流量特征、JavaScript 执行环境、交互节奏和真人存在客观差异。我负责任地建议不要在正式项目里尝试对抗这类防护系统。在测试开发阶段更合理的方式是推动开发团队在测试环境里开放白名单接口或者由测试平台提供所以的测试通行证机制确保自动化用例可以稳定执行。如果必须在生产环境做烟雾巡检也尽量用真实浏览器执行配合合理频率和随机等待任何工具都一样而且仍然可能被拦截要做好预案。自动化测试的目标是保障系统质量不是绕过系统的安全边界。这也是我这些年做测试架构的一项核心原则。最后一个实操心得写了这么多最后用我自己的体验收个尾。从 Selenium 切到 Playwright我最大的感受不是某个具体 API 多好用而是等待这件事终于被设计进工具的骨架里了。写 Selenium 用例时我的注意力大量消耗在元素什么时候出现、状态什么时候稳定这件事上写 Playwright 用例时我的注意力可以全部放在业务逻辑上——登录要验证什么、列表筛选要验证什么、导出要验证什么。这个转变带来的效率提升是巨大的。如果你正准备在新项目里引入 Web 自动化或者正在纠结要不要迁移我的建议是先拿一个执行时间最长、失败率最高的模块做试点用一周时间完成迁移和原有用例并行跑一轮对比数据和稳定性让数据帮你做决定。不用一次全量替换自动化测试也是一步步演进的过程。希望这篇分享对你有实质帮助。你的自动化测试项目跑起来遇到的具体问题也欢迎在评论区一起讨论。