从框架到平台,我用 Cursor 把 Playwright + Pytest 项目升级成了测试平台

本文摘要从框架到平台,我用 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 的终...

从框架到平台,我用 Cursor 把 Playwright + Pytest 项目升级成了测试平台

本文来源: 捉虫师-007(公众号:捉虫师-007)
原文链接: https://mp.weixin.qq.com/s/jxcfk_F8xM2_OPAcESuwwg
发布时间: 2026-10-04 09:05

从框架到平台,我用 Cursor 把 Playwright + Pytest 项目升级成了测试平台

作者: 捉虫师-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 chromium

01 录制出来的脚本,先变成一条正经用例

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」,仅供学习交流使用。

觉得内容不错?我要

打赏杯咖啡或蜜雪冰城吧
微信扫一扫
微信赞赏码
支付宝扫一扫
支付宝赞赏码
评论 暂无评论
请登录后参与评论