从框架到平台,我用 Cursor 把 Playwright + Pytest 项目升级成了测试平台
本文来源: 捉虫师-007(公众号:捉虫师-007)
原文链接: https://mp.weixin.qq.com/s/jxcfk_F8xM2_OPAcESuwwg
发布时间: 2026-10-04 09:05

作者: 捉虫师-007 发布时间: 2026-10-04 09:05
你在 PyCharm 的终端里敲下 playwright codegen ,浏览器和 Inspector 一起弹出来。你照着点了一遍登录,Inspector 里刷出十几行代码,复制到 test_login.py ,pytest 一跑,红了,报错写的是 fixture 'page' not found 。
装上 pytest-playwright 再跑,绿了。但你盯着那几行代码心里没底: 一条断言都没有 ,页面到底跳没跳转,它压根不管。
这就是很多人第一次用 Playwright 的真实样子。脚本能跑,不等于 它在测东西 。下面这五步,就是把它从「能跑」推到「能长期用」再到「能自己长脚本」的过程,每一步都有能直接抄走的代码。
先把环境装齐,后面全靠它:
pip install playwright pytest pytest-playwright allure-pytest
playwright install chromium01 录制出来的脚本,先变成一条正经用例
codegen 支持直接录成 pytest 风格的用例,不用你录完再手动改函数签名。开录的时候把 target 和输出文件 一起带上:
playwright codegen --target python-pytest -o tests/test_login.py http://testingpai.com在弹出来的浏览器里走一遍流程,录完点 Inspector 上的 Record 停掉,代码已经写进文件了。长这样:
from playwright.sync_api import Page, expect
def test_example(page: Page):
page.goto("http://testingpai.com/")
page.get_by_text("登录").click()
page.get_by_placeholder("用户名/邮箱/手机号").fill("kemi")
page.get_by_placeholder("密码").fill("123456")
page.get_by_role("button", name="登录").click()注意它只给了 page 这一个参数 ,浏览器、上下文、页面的初始化全都不在里面。这些活是 pytest-playwright 这个插件干的,不装它就报开篇那个错。
还有两处录完必须自己改。一是 page.pause() ,录制过程中点过暂停它会留在代码里,本地跑会弹 Inspector 卡住,CI 上直接挂死,删掉。二是断言,录出来的脚本只有动作没有断言,你自己补一条 expect(page.get_by_text("kemi")).to_be_visible(timeout=5000) ,不然它永远绿。
定位方式建议按这个顺序挑: get_by_role → get_by_text / get_by_label → get_by_placeholder → CSS → XPath 。Inspector 上的 Pick locator 点一下就能拿到元素,但它经常给你 XPath,前端一改结构就废,能换的都换掉。
参数、浏览器、失败取证,一次配好
这些开关别每次敲命令行,写进 pytest.ini :
[pytest]
addopts = -v
--browser chromium
--base-url http://testingpai.com
--screenshot=only-on-failure
--video=retain-on-failure
--tracing=retain-on-failure
--alluredir=allure-results
--clean-alluredir--base-url 配好之后,脚本里的 page.goto("/") 就能直接用,换环境只改这一处。 --screenshot 那三个都设成失败才保留 ,全量录的话磁盘两天就塞满,而且事后你也不会去看。调试阶段想看着浏览器动,加个 --headed 。
每次跑之前记得带上 --clean-alluredir ,不然上一次的结果会跟这次混在一起,报告里能翻出两个月前的用例。
最后两行是给 Allure 用的,下一节细说。
数据驱动,别写三组一样的代码
登录要测正确和错误两种,用 parametrize 一轮就跑完:
import pytest
from playwright.sync_api import Page, expect
CASES = [
("kemi123", "kemi123", True, "kemi"),
("kemi123", "wrongpwd", False, "用户名或密码错误"),
]
@pytest.mark.parametrize("username, password, ok, expected", CASES)
def test_login(page: Page, username, password, ok, expected):
page.goto("/")
page.get_by_text("登录").click()
page.get_by_placeholder("用户名/邮箱/手机号").fill(username)
page.get_by_placeholder("密码").fill(password)
page.get_by_role("button", name="登录").click()
expect(page.get_by_text(expected)).to_be_visible(timeout=5000)数据再多就挪到 data/login.yaml 里,测试文件只留业务动作。别把几十行字典塞在用例文件开头, 改个密码你要在代码堆里翻半天 。
02 Allure 报告:让失败那次自己留下证据
!踩坑提示 🕳
allure-pytest 只是个适配器,装完在终端敲 allure 多半提示命令不存在。报告生成器得单独装,Windows 用 scoop install allure,mac 用 brew install allure,装完重开终端敲 allure --version 确认一下。
跑和看是两步:
pytest # 结果写进 allure-results/
allure serve allure-results # 临时起服务,本地看
allure generate allure-results -o allure-report --clean # 静态报告给 CI 归档allure serve 适合自己排查, generate 出来的静态目录才能挂到 Jenkins 或 GitLab Pages 上给团队看。
光有截图不够,步骤得拆开
默认报告里一个用例就是一坨,失败了只知道最后一行报错。用 allure.step 把流程切开,报告会告诉你卡在第几步:
import allure
@allure.epic("测试派")
@allure.feature("登录")
@allure.story("账号密码登录")
@allure.severity(allure.severity_level.CRITICAL)
def test_login_fail(page: Page):
with allure.step("进入登录弹窗"):
page.goto("/")
page.get_by_text("登录").click()
with allure.step("填入错误密码"):
page.get_by_placeholder("用户名/邮箱/手机号").fill("kemi123")
page.get_by_placeholder("密码").fill("wrongpwd")
page.get_by_role("button", name="登录").click()
with allure.step("校验错误提示"):
expect(page.get_by_text("用户名或密码错误")).to_be_visible(timeout=5000)feature 和 story 标上之后,报告左侧能按模块筛,你不用在一百条用例里找自己那条。 severity 标了之后,CI 上可以只跑 critical 和 blocker 做快速冒烟 。
上一步配的 --screenshot=only-on-failure 和 --tracing=retain-on-failure ,产出的截图和 trace 会自动挂到对应用例下面,不用你手写 allure.attach 。
环境信息和失败分类,值得花十分钟配
报告里如果连 浏览器版本、被测环境 都没有,过两周你回头看,根本不知道那次红的是什么环境。让 conftest 自动记下来:
import os, platform, sys
import pytest
@pytest.hookimpl(tryfirst=True)
def pytest_sessionstart(session):
allure_dir = session.config.getoption("--alluredir")
if not allure_dir:
return
try:
from playwright._repo_version import version as pw_version
except ImportError: # 私有模块,版本变了就留空
pw_version = "unknown"
browsers = ",".join(session.config.getoption("--browser") or ["chromium"])
os.makedirs(allure_dir, exist_ok=True)
with open(os.path.join(allure_dir, "environment.properties"), "w", encoding="utf-8") as f:
f.write(f"Python={sys.version.split()[0]}\n")
f.write(f"Playwright={pw_version}\n")
f.write(f"Browser={browsers}\n")
f.write(f"BaseURL={session.config.getoption('--base-url') or '未设置'}\n")
f.write(f"OS={platform.platform()}\n")再来一个 categories.json ,把失败自动分类,报告里一眼能看出这轮 红的是脚本问题还是环境抖动 :
[
{"name": "定位失败", "matchedStatuses": ["failed"],
"messageRegex": ".*Timeout .* exceeded.*"},
{"name": "断言失败", "matchedStatuses": ["failed"],
"messageRegex": ".*Expected .* to be visible.*"},
{"name": "环境异常", "matchedStatuses": ["broken"],
"messageRegex": ".*ECONNREFUSED.*"}
]这个文件要放在报告读取的分类目录下,最省事的做法是 跑完测试后拷一份进 allure-results :
cp ci/categories.json allure-results/
allure generate allure-results -o allure-report --clean分类一上,你跟开发的对话就变了。以前是「用例又红了」,现在是 「这轮十二个红,九个是定位失败,前端昨天改了登录弹窗的结构」 。
03 PO 分层:把三十处改动压成一处
脚本写到二十条,你会撞上一个具体的事:登录按钮的文案从「登录」改成一个词,或者弹窗多了个协议勾选框。每一条用例里都写着那三行定位代码, 你要打开二十个文件改二十遍 。
PO(Page Object)就是把页面细节从用例里挪出去。目录这么分:
ui_auto/
├── pages/
│ ├── base_page.py # 通用动作:点击、输入、等待、断言
│ └── login_page.py # 登录页的定位 + 业务动作
├── tests/
│ └── test_login.py # 只写业务语义和断言
├── data/
│ └── login.yaml
├── conftest.py
└── pytest.ini目录不用照抄。就两个页面的项目,pages/ 下放两个文件一样跑得起来, 分层是按页面抽,不是按目录数量凑 。
base_page.py 放那些你会在每个页面重复写的东西:
from playwright.sync_api import Page, expect
class BasePage:
def __init__(self, page: Page):
self.page = page
def goto(self, path: str):
self.page.goto(path)
def click(self, locator):
locator.click()
def fill(self, locator, text: str):
locator.fill(text)
def should_see(self, text: str, timeout: int = 5000):
expect(self.page.get_by_text(text)).to_be_visible(timeout=timeout)!踩坑提示 🕳
BasePage 别越写越胖。它只装「每个页面都会用」的动作,一旦出现某个页面专属的判断逻辑,就该下沉到那个页面自己的类里,不然它会变成一个谁都不敢改的万能工具类。
login_page.py 只管登录页, 定位全部收在开头 :
from playwright.sync_api import Page
from pages.base_page import BasePage
class LoginPage(BasePage):
def __init__(self, page: Page):
super().__init__(page)
self.entry = page.get_by_text("登录")
self.username = page.get_by_placeholder("用户名/邮箱/手机号")
self.password = page.get_by_placeholder("密码")
self.submit = page.get_by_role("button", name="登录")
def open(self):
self.goto("/")
self.click(self.entry)
def login(self, username: str, password: str):
self.fill(self.username, username)
self.fill(self.password, password)
self.click(self.submit)用例就剩下这几行, 读起来是业务而不是选择器 :
用例层里不该出现任何选择器。看到 page.get_by_* 出现在 tests/ 里,就说明 这一层漏了,得把它挪回 pages/ 。
from pages.login_page import LoginPage
def test_login_success(page):
lp = LoginPage(page)
lp.open()
lp.login("kemi123", "kemi123")
lp.should_see("kemi")
def test_login_wrong_password(page):
lp = LoginPage(page)
lp.open()
lp.login("kemi123", "wrongpwd")
lp.should_see("用户名或密码错误")「登录弹窗多一个勾选框时,只有 login_page.py 里加两行,用例一个字都不用动。」
这就是分层唯一的目的,不是让你少写代码,是让 改动只发生在一个地方 。
也别上头。五条冒烟用例的一次性脚本,拆三层反而是绕远路,一个文件写完跑完扔掉就行。判断标准很简单: 同一个页面的定位出现在三个以上用例里,才值得抽 。
04 Cursor + Playwright MCP:让 AI 看着真实页面写
前面三步你已经能自己维护了,但写新页面的 PO 还是慢。Cursor 接上 Playwright MCP 之后,AI 能 真的打开浏览器看页面 ,而不是对着你的代码猜。
在项目根目录建 .cursor/mcp.json (想全局生效就放 ~/.cursor/mcp.json ):
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"]
}
}
}改完要 完全退出 Cursor 再开 ,光重载窗口它不认。重启后去 Settings → Tools & MCP 看,playwright 那一项亮绿灯就成了。第一次用之前先在终端跑一次浏览器安装,不然它会在你第一次提问时卡在下载上:
npx playwright install chromium验证一下,在对话框里问它页面上 H1 写的是什么。浏览器弹出来,它还你一句真实内容,就成了。
别让它自由发挥,把项目规矩喂进去
直接说「写个登录用例」,它多半给你一坨 CSS 选择器加 time.sleep ,根本不按你的 PO 结构来。你得把规矩写进 .cursorrules :
你是本项目的 UI 自动化助手,写 Python + pytest + Playwright 代码。
- 页面封装放 pages/,一个类一个页面,继承 BasePage
- 定位优先 get_by_role / get_by_text / get_by_placeholder,不用 XPath
- 禁止 time.sleep,等待统一用 expect(...).to_be_visible()
- 用例放 tests/,选择器不许出现在用例里
- 断言的期望值不确定时先问我,不要自己编然后这么提需求:
用浏览器打开测试站,走一遍登录流程(只用测试账号,不要提交真实数据)。看完之后,按 pages/login_page.py 现有的写法 ,给这个页面补一个 logout() 方法,并补一条验证退出成功的用例。
它会真去点一遍,拿到真实的可访问性树,再照着你的结构写代码。生成的定位是按角色和文案来的,不是瞎猜的 XPath,这一步比 纯靠代码上下文凭空生成 强太多。
三个你必须自己把关的地方
- 断言的期望值
它不知道你业务上应该显示什么,很容易自己编一个「登录成功」当断言,跑绿了但压根没验证。凡是它写的 expect,你都要拿真实页面对一遍。
- 偷偷加的等待
看到 wait_for_timeout 就删,改成 expect。它加这个多半是第一次没等到元素,属于用固定等待掩盖真问题。
- token 消耗
MCP 每次响应会把整棵可访问性树回传,一轮吃掉几万 token 很正常。批量生成脚本别用它,去用 codegen 或 Playwright CLI;MCP 留给看不清页面结构、需要边调边试的场景。
另外记得加 --isolated ,让它每次用干净的浏览器上下文, 不然上次的登录态会留着 ,你以为它在测登录,其实页面已经是登录后的样子。
05 从脚本到平台:AI Agent 把四步接起来
到第四步,你的效率瓶颈已经不是「怎么写」,而是 谁来写、谁来维护 。AI Agent 平台做的事,就是把前面四步串成一条不用人盯着的流水线。
拆成四层看:
| 层 | 干什么 | 用什么 |
|---|---|---|
| 意图层 | 需求或一句话 → 测试点清单 | LLM + 历史用例库 |
| 生成层 | 测试点 → PO 脚本 | LLM + Playwright MCP |
| 执行层 | 跑用例、并发、切环境 | pytest + xdist + base-url |
| 反馈层 | 失败分类、报告、自愈 | Allure + 报错快照回喂模型 |
最小可跑的版本,其实就是一个能调 pytest 并读懂结果的 Python 服务。先让它拿到失败清单:
import json, glob, subprocess
def run_suite(env: str = "http://testingpai.com"):
subprocess.run(
["pytest", "-q", "--base-url", env,
"--alluredir", "allure-results", "--clean-alluredir"],
cwd="ui_auto",
)
failed = []
for f in glob.glob("ui_auto/allure-results/*-result.json"):
r = json.load(open(f, encoding="utf-8"))
if r.get("status") != "passed":
failed.append({
"name": r.get("name"),
"message": r.get("statusDetails", {}).get("message", "")[:2000],
"trace": r.get("statusDetails", {}).get("trace", "")[:2000],
})
return failed拿到失败之后, 只有定位类失败才进自愈 。断言失败说明功能可能真变了,那是 bug,不是脚本问题,直接转人工。
def heal(case: dict, max_retry: int = 2):
for attempt in range(max_retry):
snapshot = capture_dom(case["name"]) # 截图 + 可访问性快照
patch = ask_llm(prompt=HEAL_PROMPT, context={
"case": case["name"],
"error": case["message"],
"dom": snapshot,
"file": locate_page_object(case["name"]),
})
if not patch or touches_assertion(patch): # 动了断言就拒绝
return "needs_human"
apply_patch(patch)
if not run_suite():
return "healed"
return "needs_human"三条红线,写死在提示词里: 只能改选择器,不许改断言,不许改业务流程 。自愈的本意是修「页面挪了个位置」,不是修「功能真的坏了」。一个用例重试两次还红,它就该转人工,别让机器一直试下去。
再往上,把 needs_human 的用例推到飞书或者企微群里,带上 Allure 报告链接和失败截图,人接手的时候已经有全部上下文。跑绿的用例把本次的 locator 写回历史用例库,下次生成时能少走一遍弯路。
这套东西真上线,你还得提前想清楚几件事。AI 生成的用例覆盖率不等于需求覆盖率,需求里的业务规则、权限、边界值,还得你自己过一遍;自愈改过的代码必须走 code review,不然它会静悄悄地把一个真 bug 修成绿灯;跑出来的 脏数据要有清理机制 ,别让它在你的测试环境里攒下几百条测试订单;每次调用都是真金白银的 token,批量生成走 CLI,别让 MCP 扛全部流量。
说白了,这四层里 最能立刻见效的不是生成,是反馈层 。失败分类一上,你每周花在「这用例为什么红」上的时间能砍掉一大半,这一层的投入产出比,比让 AI 帮你写脚本高得多。
本文转载自微信公众号「捉虫师-007」,仅供学习交流使用。
觉得内容不错?我要