☰
pytest+Playwright自动化测试框架搭建:从环境到报告的一小时实战指南
2026/10/21 8:07:26 网站建设 项目流程

1. 为什么是“pytest+Playwright”这对组合

这几年我做 Web 端自动化测试,从早期的 Selenium 一路走过来,最大的感受就是一个字:稳。以前用 Selenium 写用例,最怕的就是半夜跑回归脚本,早上来一看满屏红,点进去全是 Element not found。换成 Playwright 之后,这类恼人的“偶发失败”少了很多,因为它的底层把等待逻辑内置到了定位和操作里,会自动轮询元素的可见、可交互状态,满足条件才继续执行。这一条设计,几乎就能把日常 UI 自动化里超过一半的 flaky 问题消除掉。

而 pytest 这边,承担的是测试组织者的角色。它提供 fixture、参数化、插件机制、报告输出,正好把 Playwright 的浏览器控制能力包装成一个工程化的测试集合。单纯用 Playwright 写脚本也能跑,但跑完只有终端输出,没有分组、没有报告、没有数据驱动;接上 pytest 之后,每个用例可以独立断言,失败自动截图,数据驱动扩展,接 CI 也顺理成章。这篇内容,我的思路是用一小时的时间线,把这套原型完整搭起来。

说到“自动化测试原型”,我的理解是:不追求完整平台化,不追求用例覆盖率,而是用最短时间把“环境、框架、用例、报告、调试手段”这条链条跑通。原型阶段重点要验证两件事:一是被测系统的关键链路能不能被脚本稳定驱动;二是后续扩展用例时,当前的工程结构是否清晰、不混乱。一小时内你会看到我怎么把这些东西塞进一个不到一百行的工程里,并且保证它是可以直接接着继续长肉的基础骨架。

1.1 从 Selenium 转向 Playwright 的真实理由

如果你维护过有一定规模的 Selenium 项目,大概率碰到过这几个问题。第一,WebDriver 的版本和本地浏览器版本强绑定,某天浏览器自动升级,第二天整套用例崩掉,你得花半天去重新匹配 driver 版本。第二,Selenium 的等待方式太灵活,隐式等待、显式等待、强制等待,风格完全取决于写脚本的人的个人习惯,造成代码风格割裂,时间一长自己都看不懂自己写的等待逻辑。第三,想拦截网络请求、模拟多标签操作这类稍微进阶一点的需求,Selenium 写起来非常费劲。

Playwright 对这些痛点几乎是降维打击。它自带浏览器驱动管理和安装命令,不需要手动去网上下载某个版本的驱动。等待机制则是统一的自动等待,每一个定位器操作前都会先等元素达到“可操作”状态。它还有持久化上下文、网络拦截、文件下载上传、移动端设备模拟等近乎完整的浏览器自动化能力。对做原型来说,最明显的好处是不用额外引入一大堆第三方工具库。

不过我也得说句公道话:Selenium 社区成熟度高,资料量巨大,在某些特定场景下仍然是不可替代的,比如旧浏览器兼容、企业内网环境的限制。做原型优先选 Playwright,是因为开发效率和执行稳定性明显更好,同时代码量可以做到更少。

1.2 pytest 在整个体系里解决什么问题

用 Playwright 直接写脚本,本质上就是普通的 Python 同步代码,浏览器自动化 API 很直观。但用例数量一旦超过二十个,没有测试框架的约束,工程就会乱成一锅粥:环境准备代码重复、用例之间互相影响、失败信息不标准。pytest 在这里解决的是四个层面的问题。

第一,fixture 机制。浏览器实例、页面上下文、页面对象都可以声明成 fixture,pytest 自动管理创建和销毁顺序。scope 参数可以控制是每个用例重建一次,还是整个测试会话只建一次,这个粒度控制对 UI 自动化来说非常值钱。第二,参数化。同一套测试步骤,验证多组输入数据和期望结果,只需要加一个装饰器。第三,插件生态。pytest-xdist 做并行,pytest-html 做报告,pytest-rerunfailures 做失败重试,这些都是成型方案,不太需要自己造轮子。第四,断言与失败收集。pytest 的 assert 简洁直接,配合 Playwright 的 expect 断言,失败时的异常栈信息很清晰。

整套组合的逻辑其实很清晰:Playwright 负责“浏览器操作”这一层,pytest 负责“测试组织”这一层,两者通过 fixture 做连接点。这个连接点的设计是整个原型里最核心的部分,后面你会看到它有多关键。

2. 一小时原型的骨架搭建与环境准备

好,现在开始计时。我一般把一小时这样分配:前 15 分钟搭环境和工程骨架,中间 30 分钟写核心用例和封装,最后 15 分钟用来做数据驱动、报告输出和排查问题。如果你机器网络状况好,前 15 分钟还能压缩到 10 分钟。如果浏览器内核下载慢,这一段可能会拖到 25 分钟,所以建议一开始就把安装命令跑起来,让它自己慢慢下。

2.1 依赖安装与浏览器内核下载

先创建一个独立虚拟环境,这一步不要省。做自动化测试的电脑,通常同时也是开发机,如果直接把依赖装进全局环境,时间一长必然出现版本冲突。用 Python 3.10 或更高版本最省心,语法和生态都比较成熟。

python -m venv .venv # Windows 下激活 .venv\Scripts\activate # Linux / macOS 下激活 source .venv/bin/activate

接着安装依赖。pytest 和 Playwright 是必备,另外我习惯把 pytest-html 也装上,原型演示时生成一份可视化报告,给团队或业务方看,比终端日志直观得多。

pip install pytest playwright pytest-html

安装完成后,还需要下载浏览器内核。这一步是 Playwright 和 Selenium 体验差异最大的地方,直接执行:

playwright install chromium

这条命令会把 Chromium 内核下载到本机缓存目录,之后代码里启动 chromium 时,直接使用这个内置版本,不再依赖你电脑上装没装 Chrome。这个设计让执行环境的一致性得到很大提升,不同开发机上跑出来的结果不会因为浏览器版本差异而不一致。如果将来需要验证多浏览器兼容性,可以把 firefox、webkit 也一起装,但原型阶段只装 chromium 就够了。

环境是否装好,可以用一个最小脚本验证。能成功打开浏览器并输出标题,就代表一切正常。

from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page() page.goto("https://example.test") print(page.title()) browser.close()

顺便解释一下同步异步的选择。Playwright 同时提供同步和异步两套 API,做测试用例,我个人推荐同步 API。pytest 本身就是同步模型,用同步 API 配合最简单的 fixture 写法,代码可读性最好,调试最直观,性能差距在常规 UI 自动化场景下完全可以忽略。除非你想在同一个 Python 进程里跑大规模并发,否则不要一上来就异步,那是给自己找麻烦。

2.2 目录结构:conftest、pages、tests 各自放什么

原型阶段不建议一步到位搞复杂架构,但基本的分层一定要有。我习惯的结构是这样的:

autotest_demo/ ├── conftest.py # fixture 都放这里 ├── pages/ │ ├── __init__.py │ └── login_page.py # 页面对象 ├── tests/ │ ├── __init__.py │ ├── test_login.py │ └── test_order.py └── requirements.txt

每部分职责要明确。conftest.py 是 pytest 的约定文件,不需要手动导入,pytest 会自动发现它,并把里面的 fixture 共享给所有测试文件。pages 目录放页面对象,每个页面封装成一个类,核心元素定位和操作逻辑都收敛到这里。tests 目录放测试用例文件,用例只关心业务场景验证,不直接手写选择器字符串。

为什么要把元素定位从测试用例里抽出去?因为 UI 前端页面结构调整是家常便饭。一个登录框从 input#username 改成 input[name="account"],如果定位分散在十个测试用例里,就得改十处;封装进页面对象后,只改一个类里的一个属性。原型阶段就按这个规则来,后面页面多了也不会乱。

requirements.txt 在装完依赖后生成,这个习惯值得养成:

pip freeze > requirements.txt

不过要注意,pip freeze 会把传递依赖也列出来,如果想要更干净的结果,也可以手动维护顶级依赖。原型阶段直接 freeze 完全没问题,重点是有这样一个文件,换台机器能一键复现环境。

2.3 fixture 封装:浏览器与页面生命周期

conftest.py 是整个工程里最核心的部分。我给出两个 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, args=["--disable-gpu"] ) yield browser browser.close() @pytest.fixture(scope="function") def page(browser): context = browser.new_context( viewport={"width": 1440, "height": 900}, locale="zh-CN", timezone_id="Asia/Shanghai", ) page = context.new_page() yield page context.close()

这里两个 scope 的设计逻辑很关键。浏览器进程用 session 级别,整个测试会话只启动一次,因为浏览器启动开销最大,不值得每个用例都重来一次。context 和 page 用 function 级别,每个用例一个全新页面上下文,用例之间彻底隔离,cookie、localStorage 互不影响。这种“进程复用加上下文隔离”的组合,是速度和稳定性之间的最优解。

我特意在 launch 里加了 --disable-gpu 参数,这在一些无桌面环境,比如 CI 服务器、容器里,可以避免 GPU 相关的告警和崩溃。本机调试时这段加不加都行,习惯了就统一保留。

context 里设置 viewport、locale、timezone,这几个参数是原型阶段容易被忽略但实际非常有用的细节。viewport 固定为 1440×900,可以避免响应式布局在浏览器窗口尺寸不一致时导致元素位置偏移或不可见。locale 设为 zh-CN,可以让那些根据语言切换文案的页面稳定输出。timezone 同理,某些系统的时间显示会因为时区不同而不同,固定时区就能少一类偶发性用例失败。

注意:遇到“浏览器启动失败”时,优先检查两件事:一是 playwright install 是否真的执行成功;二是 headless 模式在当前系统环境是否被支持。大部分情况都是第一件的问题。

3. 核心实现:从第一个用例到 Page Object

环境准备好之后,就是重头戏。我一直强调,UI 自动化最忌讳一上来就堆五十条用例,而是先用一条跑通端到端,确认链路没有硬伤,再逐步扩展。原型阶段更是如此,要把精力集中在打通整条路上。

3.1 第一个登录用例的完整写法

假设我们有一个演示环境的后台登录页面,正常登录流程是:打开登录页,输入用户名和密码,点击登录按钮,页面跳转后出现用户头像。用 Playwright 写出来大概是:

from playwright.sync_api import expect def test_login_success(page): page.goto("https://example.test/login") page.locator("#username").fill("tester01") page.locator("#password").fill("Demo@123456") page.locator("button[type='submit']").click() expect(page.locator(".user-avatar")).to_be_visible() expect(page.locator(".user-name")).to_have_text("自动化测试员")

这段代码看起来短,但里面有三层能力值得展开。第一,goto 默认会等待页面 load 事件完成,不需要自己再写 time.sleep。第二,fill 操作会自动等待元素可交互,包括可见、可编辑,所以不需要显式等待。第三,expect(...).to_be_visible() 是一个轮询断言,它会自动重试,直到元素出现或超时,这个机制保证了页面异步渲染时用例依然稳定。

为什么断言最终落在 expect 而不是普通 assert?因为 assert page.locator(...).is_visible() 只会在断言那一瞬间检查一次,如果页面元素还没渲染完就直接判失败;而 expect 会持续轮询,元素在两三秒内出现也能通过。这个区别就是“偶发失败”和“稳定通过”的分水岭。

跑第一条用例的方式:

pytest tests/test_login.py -v

如果前 15 分钟环境搭得没问题,你这时应该能看一个绿色的 PASS。看到 PASS,就说明整条链路已经通了,从环境到定位器到断言,全部没有硬伤。

我知道很多人一开始会卡在选择器不会写的问题上,这是 Playwright 提供了录制工具的原因。执行:

playwright codegen https://example.test/login

运行后会打开一个浏览器窗口和一个录制面板,你手动点击、输入,代码会同步生成。生成的定位器直接复制到测试代码里稍微整理就能用。我在实际项目里,遇到不熟悉的新页面,第一件事就是跑一遍 codegen,把主要操作流程先录下来,这比在开发者工具里手动找 xpath 快得多。

3.2 稳定定位器的三个原则

录制生成的代码未必都是最优的,所以录完之后要按稳定性的原则整理。我总结了三条经验。

第一,优先用用户可感知的语义定位。get_by_role("button", name="登录") 比 locator("button").nth(2) 稳定得多。前者表达的是“页面上那个语义为按钮、名字是登录的元素”,后者是“第二个按钮”,一旦前端加了按钮,nth(2) 就指向别的东西。get_by_text、get_by_label 也是这类语义定位,后续维护时读起来也顺畅。

第二,选择器优先短且在文档里唯一。如果元素本身有 id,直接用 locator("#username");没有 id 就找 name、class、data-testid 这类稳定的属性。不要一上来就写特别长的一串层级选择器,比如 div.container > div.row > form > div > input,这种结构选择器对前端改动极其敏感,稍微调整一层就会挂。

第三,处理重复元素时用 filter 而不是靠位置。页面有多个相同按钮时,locator("button").filter(has_text="确定") 比 locator("button").nth(0) 更稳定。如果你发现 filter 也不好写,说明页面元素的可辨识度太差,这是被测系统的问题,实践中可以反馈给前端,同时用相对定位绕一下。

3.3 用 Page Object 收拢页面操作

原型阶段如果只有一条用例,页面对象的存在感不强,但它的价值在于后续扩展。我一直强调,原型的目的是“可用且可扩展”,所以从第一条用例开始就引入 Page Object 模式,习惯自然就建立了。

所谓 PO,就是把某个页面的元素定位和操作封装到一个类里,测试用例里不直接出现选择器字符串,只调用类的方法。我在 pages/login_page.py 里写一个登录页对象:

class LoginPage: def __init__(self, page): self.page = page self.username_input = page.locator("#username") self.password_input = page.locator("#password") self.submit_button = page.locator("button[type='submit']") self.error_tip = page.locator(".error-tip") def goto(self): self.page.goto("https://example.test/login") def login(self, username: str, password: str): self.username_input.fill(username) self.password_input.fill(password) self.submit_button.click() def get_error_message(self) -> str: return self.error_tip.inner_text()

然后 fixture 里把页面对象也封装进去:

@pytest.fixture() def login_page(page): return LoginPage(page)

测试用例就简化成:

def test_login_success(login_page): login_page.goto() login_page.login("tester01", "Demo@123456") expect(login_page.page.locator(".user-avatar")).to_be_visible()

这个简化版的 PO 不算最严格的标准,通常断言会封装进页面对象,或者拆出结果页对象。但原型阶段关键是理解收敛思路:选择器集合放一起,操作步骤放一起,用例只描述业务。方向是清晰的,后续升级为完整 PO 体系时,每个页面一个类,类里只维护本页面元素和方法,页面跳转通过方法返回值或显式导航实现。

我也见过一些团队,一上来就搞非常严格的 PO 分层,所有动作都封装起来,最后工程师大部分时间花在重构页面类上,用例反而没写几条。原型阶段我建议折中:先把选择器收拢进类里,流程写在用例中,等页面数量和重复操作增加到十个以上时,再全面铺开 PO 也不迟。

4. 数据驱动与运行效率控制

核心用例跑通后,原型大概耗时 40 分钟。剩下的时间用来让这个原型真正“自动化”起来,至少做到一个参数化用例、一份报告、一条能反复执行的运行命令。

4.1 用 parametrize 把一条用例变成一组用例

自动化测试早晚要面对“同一功能,多组输入数据”的场景。在 pytest 里,一套测试步骤跑多组数据,只需要一个装饰器:

import pytest from playwright.sync_api import expect @pytest.mark.parametrize("username,password,expected_msg", [ ("tester01", "Demo@123456", "登录成功"), ("tester01", "wrong_pwd", "用户名或密码错误"), ("", "Demo@123456", "请输入用户名"), ("tester01", "", "请输入密码"), ]) def test_login_data_driven(login_page, username, password, expected_msg): login_page.goto() login_page.login(username, password) if expected_msg == "登录成功": expect(login_page.page.locator(".user-avatar")).to_be_visible() else: expect(login_page.page.locator(".error-tip")).to_have_text(expected_msg)

这段代码把四组用例合并成一个参数化用例,执行时它会横向展开成四个独立的测试项。一旦其中某组失败,pytest 会精确爆出失败的是哪一组参数,报告里一目了然。这就是数据驱动的基本形态:数据和步骤分离,步骤只写一次。

数据源写在代码里适合少量场景,如果要维护的数据量超过几十条,建议抽到外部文件。常见做法是读取 JSON、YAML 或者 CSV,原型阶段用 JSON 就够。重点是不需要在逻辑里写一堆 if else,把数据交给 pytest 收集用例时动态注入,用例体保持干净。

4.2 标记、重试与并行:运行策略怎么定

数据驱动跑通后,接下来是很现实的问题:怎么跑得更快、更稳。

先讲标记。pytest 支持给用例打标签,然后选择性地运行。在工程根目录建一个 pytest.ini:

[pytest] markers = smoke: 冒烟测试用例 regression: 回归测试用例

在测试文件里打上标记:

@pytest.mark.smoke def test_login_success(login_page): ...

然后运行:

pytest -m smoke -v pytest -m "not smoke" -v

这样原型演示时就可以说,一条命令跑冒烟,一条命令跑全量回归。实际项目里,这个机制配合 CI 的定时任务,就能做到“每次合并代码后自动跑冒烟”。

再讲重试。UI 自动化哪怕用 Playwright 也难免偶发失败,比如第三方接口超时、页面样式加载卡顿。这时候清理根本问题是该做的,但对不稳定环境做兜底也是现实的。重试插件是 pytest-rerunfailures,安装后直接指定次数:

pip install pytest-rerunfailures pytest --reruns 2 --reruns-delay 1

我个人对重试的立场是:重试只是兜底,不是掩盖问题。如果同一个用例反复重试依然失败,说明被测功能有问题,或者用例本身写得脆弱。可以在 CI 允许重试一次,但每次都去查真实失败原因,不允许用“反正会重试”来糊弄。

并行也是效率手段。pytest-xdist 支持多进程并行执行:

pip install pytest-xdist pytest -n 3

但并行不能无脑开。用 -n 3 时,三个浏览器会同时启动,对本机资源是负担,而且如果用例间存在共享状态,比如都写同一个临时文件,就会被并行跑乱。原型阶段建议先用 -n auto 观察,有资源耗尽再手动调小。

4.3 报告输出与失败截图

最后,原型要给团队看。终端里的绿色红色虽然直观,正式一些还需要 HTML 报告。

pip install pytest-html pytest tests/test_login.py --html=report.html --self-contained-html

生成的 report.html 是单个文件,方便直接分享。打开能看到用例总数、通过率、失败信息。如果在 fixture 里加了失败截图逻辑,报告的价值还能提升不少。

失败截图的做法可以在 conftest.py 里用钩子实现:

import os import pytest from datetime import datetime @pytest.hookimpl(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: screenshot_dir = "screenshots" os.makedirs(screenshot_dir, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"{screenshot_dir}/{item.name}_{timestamp}.png" page.screenshot(path=filename)

这段逻辑的意思是:每个用例执行结束后,如果状态是失败,就从 fixture 参数里取出 page 对象,截图存到 screenshots 目录。有了这张图,排查问题时不用再猜“页面到底渲染成了什么”,直接打开图就行。

5. 常见问题与一小时调试实录

最后这部分,我整理一下实际使用中高频遇到的问题和解决办法。这些问题我看过很多人踩,也踩过很多次,整理成速查形式更实用。

5.1 快速定位失败的三个思路

第一个思路,先分清楚是“用例问题”还是“被测系统问题”。做法很简单,手动打开浏览器,按照用例步骤走一遍:能走通说明自动化脚本有问题,走不通说明需求或功能本身变化了。这个区分能省掉一半无效排查时间。

第二个思路,截图和 trace 优先于猜测。Playwright 在调试阶段,我会在关键步骤后加 page.screenshot(path="debug.png"),或者直接用失败钩子自动截图。如果问题很诡异,用 trace 模式:

context.tracing.start(screenshots=True, snapshots=True) # 执行你的测试步骤 context.tracing.stop(path="trace.zip")

生成的 trace.zip 可以用 Playwright 的 Trace Viewer 打开,完整回放页面执行过程、网络请求和控制台日志。这已经是我排查偶发失败的首选方案,比反复在代码里打印日志高效太多。

第三个思路,把用例拆到最小。一个复杂用例失败后,把多步骤用例拆成几段,逐步缩小范围,定位到具体是哪一步失败,再针对那一步单独调试。这个二分法看起来很笨,但在脚本非正常失败时往往是最快的途径。

5.2 常见问题速查表

现象常见原因处理建议
所有用例第一次都失败浏览器内核没安装先执行 playwright install chromium
定位元素超时选择器不对或元素在 iframe 里用 codegen 重新录制,或用 frame_locator 定位
用例之间登录状态互相污染复用了同一个 context确保 context 是 function 级别,每个用例新建
偶发点击没有反应页面被浮层遮挡等待浮层消失再操作,必要时点击前置步骤排列
中文输入出现乱码键盘事件层级不对优先使用 fill,而不是 press_sequentially 输入中文
时间显示不稳定测试环境未固定时区在 context 参数中设置 timezone_id 和 locale
并行执行报错用例间共享文件或状态排查用例依赖关系,让用例彼此独立
无头模式启动失败系统缺少依赖库按报错提示安装对应系统依赖

表中每个问题都值得单独聊,这里挑几个高频的展开。

第一个高频坑是 iframe。很多后台系统嵌入了第三方页面,元素明明在 DOM 里,但主页面定位器就是找不到,因为它属于 iframe 内部文档。解决办法是改用 frame_locator:

frame = page.frame_locator("#iframeId") frame.locator("#username").fill("tester01")

frame_locator 的定位器同样支持自动等待和断言,使用方式和主页面定位器几乎一样。

另一个常见问题是多标签。Playwright 里新开标签页会创建一个新的 page 对象,你需要监听 context 的 page 事件:

with context.expect_page() as new_page_info: page.click("a[target='_blank']") new_page = new_page_info.value new_page.wait_for_load_state()

打开新页面后要尽快把后续操作切到 new_page 上,如果还在旧 page 上操作,就会在原来的页面里找不存在的元素,报错信息会让人困惑。

5.3 headless 模式和调试命令的取舍

原型阶段的默认模式是 headless,也就是无头浏览器,跑得快、不弹窗、适合 CI。但调试的时候,我强烈建议临时关闭 headless,看着浏览器一帧一帧执行操作,比任何日志都直观。有两种做法:

一是在 fixture 里临时把 headless 改成 False;二是用 Playwright 的 page.pause() 调试命令。在脚本里插入:

page.pause()

运行到这一行时,浏览器会停在原地,并自动打开 Playwright Inspector。这个时候可以手动操作页面、查看定位器、观察网络请求。这个调试体验比我用过的很多框架都舒服。

在 CI 环境里,headless 是必须的,因为容器通常没有桌面显示服务。无头模式运行时如果报缺少某个库,思路很简单:根据报错提示安装缺失的系统库,而不是盲目重装浏览器。这个问题在各类 Linux 容器里都常见,关键是系统发行版不同,依赖名称也不同,照着报错走就行。

以上是一个完整的一小时原型搭建路径。最后分享一点时间分配的小经验。

把一小时切成四段:前 15 分钟搭环境和骨架;中间 20 分钟写完第一组用例并让它跑通;再 15 分钟做数据驱动和报告;最后 10 分钟应对意外情况,比如浏览器下载慢、某个定位器选不到元素。如果严格按照这个节奏走,绝大多数情况下你都能在 55 分钟左右看到一个参数化用例集合全部 PASS。

万一某个环节卡住超过 10 分钟,我的经验是不要再死磕。先把问题记下来,换一个更简单的方式验证链路。比如你本来想跑“下单流程”,但下单卡在某个选择器上,就先只跑“打开商品页”这一步,验证环境没问题,再逐步往上叠步骤。原型的意义就是允许你用最保守的笨办法先实现,后续再优化。

这个组合我用了大概一年,从个人原型做到了团队层面的小规模自动化回归,一直是这套骨架。如果你正准备上手 UI 自动化,建议今晚就留出一小时,搭一个你自己的登录用例原型。踩过一次真实环境,比读十篇资料都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询