做Web自动化测试这几年,我先后用过Selenium、Pyppeteer,也写过不少基于原生CDP的脚本,直到Playwright出现之后,工作流才真正稳定下来。Playwright是微软开源的一个跨浏览器自动化框架,支持Chromium、Firefox和WebKit三大内核,一套代码可以跑在不同的浏览器上,提供统一的高级API。它最打动我的是内置的自动等待机制——传统框架那种手工sleep、显式等待、隐式等待混着用的狼狈局面,在这里几乎不需要了。这篇文章就从实际体验出发,把Playwright入门阶段最该弄清楚的东西讲透,环境怎么搭、脚本怎么写、选择器怎么用、常见坑有哪些、如何跟pytest一起搭成可维护的测试框架,一次讲清楚,看完就能直接上手做项目。
我的建议是,这篇文章适合两类人看:一类是被Selenium折磨过、想换一个更省心的自动化方案的同学;另一类是刚开始接触Web自动化、希望少走弯路的新手。如果你已经有Python基础,大概半天时间就能把核心流程跑通。
1. 为什么做Web自动化我首选Playwright
1.1 老牌工具和Playwright的差别在哪
早几年做Web自动化,不少人第一反应是Selenium。Selenium能跑很多浏览器,生态也成熟,但它在实际项目里经常遇到几件让人头疼的事:元素还没加载出来脚本就去找了,于是你得满屏写WebDriverWait;同一个元素有时候能点到有时候点不到,稳定性全看运气;还有driver和浏览器版本不匹配的报错,换个环境就得折腾半天。
Playwright从设计上就把这些问题当作核心矛盾来解决。它采用浏览器内核级别的控制方式,通过CDP协议和浏览器直接通信,而不是像Selenium那样走WebDriver协议。这种底层优势让它对页面状态感知更精准,自动等待、网络拦截、多页面管理都做得更顺手。
我用一个生活里常见的场景打比方:Selenium就像是给司机一本交规,告诉他什么时候该踩刹车,具体操作还得靠人判断;Playwright更像是一辆带自动辅助驾驶的车,你只告诉它目的地,它自己会判断什么时候停下、什么时候该等、什么时候再起步。这个差异听起来简单,实际写脚本时节省的心力非常大。
1.2 Playwright的核心主张:少写等待代码
Playwright最核心的设计理念是“操作会自动等待直到元素可操作”。这句话值得好好理解:当你对一个元素执行click、fill、press等操作时,框架会主动等待该元素完成一系列前置状态检查——元素出现在DOM中、可见、不处于disabled状态、不被其他元素遮挡。全部通过后才会真正执行操作。
这意味着过去那些需要开发者手工判断的活,框架自己都干完了。刚接触的时候不少人会怀疑:这样会不会增加运行时间?其实不会。Playwright的默认超时是30秒,但绝大多数操作都在几百毫秒内完成,因为它每秒轮询检查一次状态,条件一满足就立刻执行,不像sleep那样固定罚站。我做过对比,同一条脚本用Playwright比Selenium + 自写等待慢不了多少,但稳定性高一个量级。
1.3 一套脚本跑三种浏览器
Playwright支持Chromium、Firefox和WebKit。跨浏览器测试是它的招牌能力,同样的脚本换一个browser_type,就能在另一种浏览器引擎上运行。这个能力在生产项目中价值很高——很多线上问题恰恰是浏览器兼容性导致的,尤其是针对苹果设备的WebKit内核页面,以前想测还得专门找环境,现在直接能覆盖。
安装Playwright的时候,它会提示你是否下载浏览器内核。这里我多说一句,很多新手会跳过这一步,导致运行脚本时报浏览器没找到。实际上playwright install这一步是必须的,它会下载三个浏览器内核到本地指定目录,不需要额外安装驱动之类的东西。
2. 环境准备与第一条自动化脚本
2.1 安装环节的细节和版本选择
环境准备其实就两步:装Python库、装浏览器内核。我推荐用虚拟环境来管理项目依赖,避免不同项目之间的包互相污染。命令也很简单:
pip install playwright playwright install chromium如果你只需要跑Chromium,装一个内核就够。如果要做完整的跨浏览器测试,就三个都装上。这里提醒一个常见问题:浏览器内核体积不小,好几个G,如果你的网络环境不太好,下载可能会超时。遇到这种情况可以先只装Chromium,毕竟大多数自动化场景用Chromium都能覆盖。
Python版本方面,Playwright对Python 3.8以上都兼容,长期维护版本更是优先支持。如果用的是公司内网环境,下载包走的是内部源,playwright install下载浏览器时可能需要配置代理环境变量,这个在自动化部署的时候尤其容易出现。
2.2 五步写出一条能跑的脚本
装好环境之后,我的习惯是先写一个冒烟脚本,把链路跑通。一条最基础的Playwright脚本包含五个步骤:导入库、启动浏览器、打开页面、操作元素、断言结果。下面这段代码就是最小可运行版本:
from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() page.goto("https://example.com") page.fill("#search-input", "Playwright入门") page.click("button[type='submit']") page.wait_for_selector(".result-item") print("页面标题:", page.title()) browser.close()这里有几个需要解释的点。sync_playwright()上下文管理器是同步API的入口,所有操作都在这段上下文里执行;headless=False表示有头模式,能看到浏览器窗口,方便调试;page.goto()是页面导航,默认会等页面load事件触发才返回,但也别太依赖这个,后面会讲SPA页面怎么处理。
当你看到浏览器自动打开、自动填内容、自动点击、最后打印出标题的时候,恭喜你,Playwright的整条链路已经通了。接下来的所有能力,都是在这条链路上延伸。
2.3 同步API和异步API怎么选
Playwright提供两套API:同步的sync_api和异步的async_api。同步API在普通脚本、爬虫、快速验证场合特别方便,写起来直观,心智负担低。异步API则适合高并发场景——比如同时处理多个页面、多个浏览器实例,或者已经使用了asyncio的Web服务项目。
用异步API时记得在Jupyter或某些交互环境里要先启一个事件循环,否则会报错。我自己在实际项目中,日常自动化测试用同步API就够了;只有需要轮询大量数据抓取的时候才用异步版本。明确了需求再选,别为了“性能”盲目上异步,同步代码的逻辑清晰度在调试时是真的香。
3. 核心细节拆解:自动等待、选择器、何时用等待
3.1 自动等待的底层逻辑
前面我说过自动等待是Playwright的灵魂,现在细看它到底做了什么。以page.fill("#username", "tester")为例,内部流程是这样的:先定位到#username元素,然后等它出现在DOM里;接着等它可见,再检查它是否已启用(enabled);最后执行填入操作。每一步的检查默认循环30秒,每0.1秒左右轮询一次。
如果某个条件始终不满足,Playwright会抛出超时异常,并且在报错信息里附上当前页面的状态、元素的状态、甚至一张截图(需要在配置里打开trace功能)。这种报错信息质量是传统工具没法比的,定位问题的时间能缩短一半以上。
还有一个细节容易被忽略:Playwright的自动等待不是对所有操作生效的。比如page.wait_for_timeout()这种强制等待,除了特殊调试场景,一般不建议用,它会无脑睡眠指定时间,打断了自动等待的节奏。还有page.goto()的等待策略,默认等待load事件,但SPA页面往往load之后内容还在渲染,这时候如果后续操作依赖了动态数据,最好用page.wait_for_selector()配合具体元素条件来等待,而不是依赖页面加载事件。
3.2 选择器是门学问,locator才是正解
Playwright提供两种定位方式:老的page.click(selector)直接传选择器字符串,新的page.locator(selector)先拿到元素对象再二次操作。我强烈建议优先用locator方式,因为它的可组合性更强,还自带严格模式的校验:当你定位到的元素有多个时,Playwright会直接报错,逼你收敛到唯一匹配。
选择器支持CSS和XPath,也支持文本选择器。但实际项目中定位元素最好优先加>from playwright.sync_api import sync_playwright, expect def test_login_and_fetch(): with sync_playwright() as p: browser = p.chromium.launch(headless=False, args=['--start-maximized']) context = browser.new_context( viewport={'width': 1920, 'height': 1080}, locale='zh-CN' ) page = context.new_page() page.goto("http://localhost:8080/login") page.get_by_label("用户名").fill("admin") page.get_by_label("密码").fill("123456") page.get_by_role("button", name="登录").click() # 等待首页关键元素出现 expect(page.get_by_text("欢迎回来")).to_be_visible() # 抓取表格数据 rows = page.locator("table tbody tr") count = rows.count() print(f"表格行数: {count}") for i in range(count): print(rows.nth(i).inner_text()) context.close()
注意几个细节:我通过new_context()创建了一个独立的浏览器上下文,这样每次测试之间能做到数据隔离,互不干扰。设置viewport是确保页面按桌面端分辨率渲染。locale='zh-CN'是因为页面的输入提示是中文,有些元素定位依赖文本内容,地区设置必须匹配。
从conftest角度来说,如果你在公司项目里想共享登录状态,可以用context.storage_state()保存登录Cookie,下次测试直接恢复,这个下面详细说。
4.2 网络拦截:Mock接口返回的高阶玩法
Preact、React、Vue这些前端的页面,接口返回慢、依赖后端环境,是自动化测试的痛点。Playwright提供了一个非常强的手段:路由拦截。你可以直接把某个接口的返回数据替换成手写的假数据,页面拿到的数据和真实请求一样。
def mock_api_response(page): def handle_route(route): route.fulfill( status=200, content_type="application/json", body='{"code":0, "data": [{"id":1, "name":"测试项目A"}]}' ) page.route("**/api/project/list", handle_route)这段代码拦截了所有指向/api/project/list的请求,不管后端环境是否可用,页面一律收到我们准备好的JSON数据。用于前端自动化测试时,可以完全脱离后端依赖,想测什么场景就伪造什么数据。
路由拦截还有一个常用场景是去除第三方依赖,比如页面加载了百度统计、腾讯地图之类的脚本,测试不需要这些外部资源,可以拦截后直接跳过,加快测试速度。这个动作我每次搭新项目都会加上,节省的时间非常可观。
4.3 移动端模拟:一套代码测手机页面
Playwright对移动端的模拟能力也做得不错。不需要真机,不需要安装额外的Appium环境,只需要设置device_scale_factor、is_mobile、has_touch几个参数,就能模拟iPhone或安卓手机上的浏览器行为。
from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=True) iphone_device = p.devices['iPhone 13'] context = browser.new_context(**iphone_device) page = context.new_page() page.goto("https://example.com") print(page.title())实际工作中,移动端网页的主要问题往往不在布局而在交互方式,比如触控事件、横竖屏切换、软键盘弹出。Playwright的设备模拟能处理大部分基础场景,但如果要测App本身,那不在本篇文章范围内,我还是聚焦在Web自动化。
5. 常见问题与排查技巧实录
5.1 超时问题:到底是我定位不对还是页面真没加载
超时异常是Playwright里最常见的错误。报错信息里通常会有两个信息非常重要:一是定位器使用的选择器,二是元素最后的快照状态。排查思路我建议按顺序排查:
- 选择器是否写错:先在浏览器的开发者工具里用元素检查验证选择器能匹配到元素。
- 是否在iframe内:如果目标元素在iframe里,必须先
page.frame_locator()切到对应frame再操作。 - 页面是否真的有这个元素:进入页面后
page.content()或page.screenshot()看一下当前实际状态。 - 是否有多个元素匹配导致严格模式拒绝:报错信息里会明确提示“resolved to 2 elements”。
我自己踩过一个印象深刻的坑:某页面弹层里的元素定位不到,检查了半天发现弹层是在shadow DOM里,普通CSS选择器根本穿不进去。Playwright对shadow DOM的支持已经算不错了,但需要明确指定穿透方式,否则看起来差不多的选择器就是匹配不上。
5.2 新标签页处理
点击页面上一个target="_blank"的链接时,Playwright不会自动帮你切换到新页面。需要监听page.context.on("page")事件,拿到新page对象再继续操作:
with page.context.expect_page() as new_page_info: page.click("a[target='_blank']") new_page = new_page_info.value new_page.wait_for_load_state() print(new_page.title())这种写法很绕,但熟练之后其实很好用,关键是理解expect_page()是一个上下文管理器,它会等待新页面事件触发。类似的事件还有response、request、dialog等,都能用来处理异步场景。
5.3 登录状态的复用
做自动化测试最烦的就是每条用例都得登录。Playwright提供了storage_state来保存登录后的Cookie和localStorage数据。第一次跑登录流程时把状态存下来,后面的用例直接加载状态:
# 先登录,保存状态 context = browser.new_context() page = context.new_page() page.goto("/login") page.fill(...) page.click(...) context.storage_state(path="auth.json") # 之后的用例直接恢复状态 context = browser.new_context(storage_state="auth.json")这里有一个细节,如果你的站点登录后还需要校验IP或设备指纹,光靠storage_state可能不够,还需要复用user_agent和viewport,最好把它们一起放进context配置里。另一个值钱的技巧是给auth.json加上异常处理,在CI环境里如果状态文件不存在,就自动先跑一遍登录用例生成状态,这样本地和远程都能用同一个入口。
5.4 几个调试和定位的杀手锏
Playwright提供了非常多的调试辅助工具,这里列几个我日常最常用的:
page.screenshot(path="debug.png", full_page=True):全页截图,看页面到底长什么样。page.pause():在脚本中间暂停,打开Playwright的Inspector面板,手动查看页面状态。--trace on:录制整个测试过程的trace文件,之后可以在playwright show-trace里逐帧回放操作。page.wait_for_timeout(500):临时调试用的硬等待,但不要留在正式代码里。
这些工具和直接打印日志相比,最大的优势是能保留操作轨迹和页面状态快照,就像黑盒测试时的行车记录仪,定位问题效率翻几倍。
6. 结合pytest搭一个可维护的测试小框架
6.1 fixture管理:别把浏览器启动写在每条用例里
前面讲的都是怎么用Playwright,但实际项目落地的时候,你一定有个更核心的需求:怎么组织测试用例、怎么管理浏览器生命周期、怎么复用登录状态。这时候pytest肯定是首选,关键是要用好fixture机制。
我推荐的基础架构是这样:
import pytest from playwright.sync_api import sync_playwright @pytest.fixture(scope="session") def browser(): with sync_playwright() as p: browser = p.chromium.launch(headless=True) yield browser browser.close() @pytest.fixture() def page(browser): context = browser.new_context( storage_state="auth.json", viewport={"width": 1920, "height": 1080} ) page = context.new_page() yield page context.close()这里用scope="session"让浏览器只启动一次,用scope="function"让每个用例都新建一个context。这样做的原因前面说了,context之间天然隔离Cookie、缓存和存储数据,保证用例之间不会互相污染。生产级别的项目里,还会把base_url、账号密码等放在配置文件中,fixture里再统一注入。
6.2 测试报告、截图留档和CI集成
pytest配上html插件,可以生成漂亮的HTML测试报告:
pip install pytest-html pytest --html=report.html --self-contained-html配合Playwright的失败截图功能,可以在用例失败时自动留下关键证据。这里给出一个简单的实现:
@pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): outcome = yield report = outcome.get_result() if report.when == "call" and report.failed: page = item.funcargs.get("page") if page: page.screenshot(path=f"report/{item.name}_failed.png", full_page=True)这段代码用pytest的钩子来捕获失败结果,从fixture里拿到page对象,失败时自动截图。在CI环境里,这些截图和HTML报告就是最直观的用例诊断材料。配合GitHub Actions或者Jenkins跑定时任务,一套完整的自动化回归体系也就搭起来了。
6.3 项目里的文件组织建议
动手搭框架的时候,我喜欢把目录分得清爽一点。当前推荐的项目结构长这样:
project/ ├── tests/ │ ├── conftest.py # 公共fixture │ ├── test_login.py │ ├── test_project.py │ └── test_report.py ├── utils/ │ ├── config.py # 环境配置、账号管理 │ └── storage_auth.py # 登录状态管理 ├── data/ │ └── auth.json # 登录状态文件 ├── report/ # 测试报告和截图 └── requirements.txt目录结构本身不复杂,核心思想是把配置、公共逻辑、用例分开,避免用例之间出现隐式依赖,同时让新成员接手项目时能快速定位各类代码的位置。
7. 性能优化:让Playwright跑得更快更稳
7.1 并发控制
默认的pytest测试是串行的,用例多了跑太久。可以用pytest-xdist插件做多进程并行:
pip install pytest-xdist pytest -n auto并行跑的代价是每个worker都要准备一套浏览器实例,内存消耗会明显上涨,CI环境的资源要预留够。我自己在本地跑的时候一般用-n 2,CI上用-n 4甚至更多,具体看机器配置。
7.2 跳过不必要的资源加载
很多外部资源对自动化断言毫无用处,比如统计脚本、广告脚本、字体文件。拦截掉它们可以减少网络开销,显著提速:
def block_third_party_requests(page): def should_block(url): return any(s in url for s in ["analytics", "google-analytics", "cdn-baoguo"]) page.route("**/*", lambda route: route.abort() if should_block(route.request.url) else route.continue_())这个路数的核心是判断URL里是否包含已知的三方域名,命中就abort()。要谨慎的是别把自己业务的前端资源也拦掉,那会导致页面渲染失败。因此这个列表的维护要花一点心思。
7.3 持久化Context避免重复初始化
前面提到存储状态,这里再往前一步:如果测试场景有很多前置步骤,可以考虑直接保存整个context的持久化数据。Playwright提供launch_persistent_context,它会把用户数据目录保存到本地,重跑时页面状态、登录态、localStorage都能恢复,特别适合那些启动流程特别重的系统。
不过使用这个特性时要注意,持久化Context会让用例之间不再相互隔离,所以只适合特定的冒烟或演示场景,真正做回归测试还是保持每个用例独立比较稳妥。
8. 写在最后的一些心得体会
Playwright用久了之后,我最大的感受是,它的价值不在于少写了多少行代码,而在于它把自动化测试的思考方式往更健康的方向引导。以前写Selenium脚本,大量时间花在那个时刻状态对不对、要不要等一下的琐碎判断上;用Playwright之后,可以把注意力集中在业务流程本身——用户要做什么、系统应该返回什么、异常情况下怎么表现。
如果你刚开始入门,我的建议是先动手把登录流程跑通,然后试着写一个数据断言,再去接触路由拦截和移动端模拟这些进阶特性。一次别铺太大摊子,一条条体验它的设计巧思,你会慢慢发现这个框架很多表面看起来神奇的能力,底层逻辑都自洽得让人舒服。
最后再分享一个小技巧:如果你的脚本在本地跑得好好的,推到CI上却频繁超时,往往不是代码问题,而是CI环境的浏览器沙箱权限受限。在启动浏览器时加上一行browser = p.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"]),很多时候问题就解决了。这种环境类问题排查起来最耗时间,建议你在项目初期就把这些参数固化在fixture里,免得到时候再踩坑。