1. 从零搭建一套能落地的Web UI自动化框架,先想清楚这几件事
这几年做Web UI自动化,绕不开一个名字叫Playwright。可能有人还在纠结Selenium是不是江湖地位更稳,但我的结论很明确:如果是新项目,直接选Playwright + pytest这套组合,省心程度不是一点半点。Playwright自带一套非常完整的浏览器自动化能力,pytest又是Python生态里最成熟、插件最丰富的测试框架,两者一拼,几乎覆盖了用例编写、执行、断言、报告、并发、持续集成里会遇到的所有常规需求。
我实际用这套框架跑了几个项目的回归测试,从登录、购物流程到后台管理系统的权限校验,稳定性和可维护性都比之前用Selenium + unittest的旧方案好太多。最直观的感受是:Playwright的自动等待和网络控制能力,把过去大量用来处理“元素找不到”“等待时间不够”的脏活累活给摘掉了;pytest又把用例的组织、数据驱动、失败重跑、报告输出这些事情收拾得明明白白。说白了一句话:框架搭得好的时候,测试工程师大部分精力花在业务场景分析上,而不是跟浏览器和断言较劲。
这篇文章不是那种放一堆代码然后让你“照着抄就行”的教程。我会把框架从零到能跑通、能复用、能上CI的完整过程拆开来讲,包括每个设计选择背后的原因、实际踩坑的细节、以及一些网上资料不会写的小技巧。不管你是刚入门自动化测试的新手,还是正在改造旧框架的从业者,只要你能看懂一点Python,就能跟着把这套东西落地到自己的项目里。
1.1 为什么是Playwright而不是Selenium
先说最扎心的一点:Selenium的WebDriver虽然兼容性广,但它本质上是在浏览器外面起一个驱动进程,再通过协议去控制浏览器。这套架构带来的问题就是慢、不稳定,而且不同版本的驱动和浏览器版本之间经常闹脾气。我在老项目里最头疼的就是环境问题,代码本身没毛病,跑起来却因为Chrome升级导致WebDriver版本对不上,回归一次得先花半小时清理环境。
Playwright完全不同。它直接把浏览器控制能力做进了自己的工具包里,通过CDP协议跟浏览器通信,不需要额外装驱动。这意味着什么?一是安装配置极简,二是执行速度确实更快,三是它提供了一套非常强的自动等待机制。比如你在页面上点一个按钮,Playwright会自动等待按钮出现在DOM里、可见、可点击,再执行点击,整个过程不需要你手动写sleep或者显式等待。写过Selenium的人应该都懂,这类等待逻辑是测试脚本里最烦人、最容易脆弱的代码。
还有一个非常关键的点,Playwright支持多浏览器,包括Chromium、Firefox和WebKit,而且同一套代码可以在无头和有头模式之间自由切换。之前用Selenium做跨浏览器测试,要分别装驱动、分别调试,现在一个API搞定。对于需要兼容多种浏览器的Web项目,这个优势是决定性的。
1.2 pytest在测试框架里的角色
pytest本身不是为UI自动化设计的,它是通用的Python测试框架。但正因为通用,它的fixture机制、参数化功能、插件生态,恰好能把UI测试里那些重复的“启动浏览器、打开页面、登录、清理数据”流程给抽象出来。
fixture可以理解成一个“带准备和清理的依赖注入器”。比如我可以定义一个session级别的fixture,负责启动一个浏览器实例,整个测试会话复用;再定义一个function级别的fixture,负责每个用例都打开新的页面上下文。这样做的好处是:浏览器进程不反复启动,页面上下文又是隔离的,用例之间互相不串数据。
pytest还支持参数化,同一个用例可以传多组数据进去跑。这个在UI测试里极其实用。比如登录用例,我可以把正常账号、错误密码、空用户名、锁定账号这些case都写成一个用例函数,用参数列表驱动,一份代码覆盖N个场景。配合pytest的插件,还能做失败重试、生成Allure报告、并行执行等等。总而言之,pytest是整个框架的骨架,Playwright只是被它调度和装配起来的执行引擎。
2. 框架整体设计与目录结构
框架搭得漂不漂亮,不是看代码多花哨,而是看新增一条用例时,你要动几个文件。我见过很多“自动化测试框架”,一个文件几千行,用例之间大量复制粘贴,改一个页面元素要在十几个用例里找。这种框架跑起来越快,维护成本越高,最后只能推到重来。所以设计的第一步就是定目录结构,让关注点分离。
2.1 模块划分:不是把所有代码堆进test用例
我推荐的目录结构大概长这样:
project/ ├── config/ # 配置文件与环境定义 │ ├── settings.py │ └── env_config.yaml ├── pages/ # PageObject层,每个页面一个文件 │ ├── base_page.py │ ├── login_page.py │ └── dashboard_page.py ├── test_cases/ # 用例层,只写业务场景和断言 │ ├── conftest.py │ └── test_login.py ├── utils/ # 通用工具,比如截图、日志、数据处理 │ ├── logger.py │ └── screenshot.py ├── reports/ # 测试报告与运行日志 ├── requirements.txt └── pytest.ini很多初学者喜欢把用例直接写在操作逻辑里,比如打开浏览器、输入账号、点登录、断言,全在同一个函数里。看起来简单,但一旦页面UI变化,你就要把所有用例翻出来改一遍。PageObject模式的核心思路是:每个页面封装成一个类,类里面只描述这个页面的元素定位和页面操作行为,用例里只表达“用户干了什么”“结果应该是什么”。登录页就不关心其它页面,Dashboard页就不需要知道登录按钮在哪。
BasePage这个类就更基础了,一般会封装Playwright的通用操作,比如点击、填写、等待、拖动、滚动。其它页面类继承BasePage,只写自己的专属元素和方法。这样分层之后,底层库换版本、页面结构调整、业务场景新增,影响面都被限制在一个很小的范围内。
2.2 配置管理与环境切换
Web自动化最常遇到的环境问题就是:测试环境、预发环境、生产环境,URL不一样,账号不一样,可能还有一些功能开关也不一样。如果这些配置硬编码在用例文件里,那换环境就得改代码,非常危险。
我的做法是用一个yaml文件维护不同环境的配置,再在程序里根据环境变量读取对应数据。settings.py负责加载配置,整个框架里所有需要URL、账号、密码的地方,都从配置中心拿,绝不手写在用例里。这样只需要在运行命令时指定测试环境,比如:
export TEST_ENV=staging pytest test_cases/test_login.py代码里就能通过os.getenv拿到“staging”对应的配置字典。千万别小看这一步。曾经我在一个项目里就是因为把测试环境的域名硬编码到了脚本里,结果预发环境验证的时候全部用例跑挂,排查了半天才发现是配置没切换。配置中心化之后,这种低级问题再也没有出现过。
2.3 核心公共组件:PageObject与BasePage
我再展开说一下BasePage的封装思路。BasePage里会注入Playwright的Page对象,然后提供几个最常用的方法,比如:
fill_text(selector, text):清空输入框再填入文字click_button(selector):点击前等待元素可点击get_text(selector):获取元素文本用于断言wait_for_element(selector, timeout):显式等待某个元素出现take_screenshot(name):失败时截图留存
这些方法内部都用Playwright的locator语法写,定位策略统一。为什么这么做?因为直接暴露原始Page对象给用例,容易出现“各种写法五花八门”的情况。比如同样是定位一个按钮,有人用page.query_selector,有人用page.locator,还有人用XPath,代码风格完全失控。把公共操作收拢到BasePage之后,编写用例的人只需要记住几个方法,代码可读性一下子上去了。
3. 关键依赖安装与pytest插件实战
框架的代码结构不是全部。真正决定体验的是依赖选型和插件配置。这里我会说清楚我选了哪些库、每个库解决什么问题、有哪些坑必须避开。
3.1 安装清单与版本选择
我的requirements.txt长这样:
playwright==1.45.0 pytest==8.2.2 pytest-playwright==0.5.1 pytest-xdist==3.6.1 pytest-rerunfailures==14.0 allure-pytest==2.13.5 PyYAML==6.0.1需要解释一下几个关键项。pytest-playwright是官方提供的pytest集成插件,装上之后它会自动注册几个fixture,比如browser、context、page,你不需要自己写浏览器启动逻辑。这个插件大大降低了入门门槛,但如果你有高度定制需求,它也不拦着你覆盖默认fixture。
pytest-xdist用来并行跑用例,多核机器上速度提升非常明显。pytest-rerunfailures负责失败用例自动重试,UI自动化里有很多偶发性的弹窗、网络抖动、动画未结束问题,先重试一次,能过滤掉大部分非真正的功能bug。allure-pytest则是生成那种带步骤、截图、日志的漂亮报告。PyYAML用来解析配置文件。
还有一个必须做的步骤:安装Playwright之后,记得执行一次浏览器二进制文件的下载命令:
playwright install不执行的话,运行时它会直接提示找不到浏览器。这里有个坑:如果你在容器或CI环境里跑,还需要额外安装系统依赖库,否则浏览器可能启动不了。用下面的命令安装系统依赖:
playwright install-deps千万注意,这个命令在某些精简版Linux镜像上会缺一些常见库,比如libX11、libnss3,虽然install-deps会自动装,但公司内网环境如果限制外网,你可能需要把这些包提前打底。否则跑起来全是“浏览器启动失败”的报错。
3.2 在pytest中配置Playwright的fixture
即使靠pytest-playwright可以拿到现成的page fixture,我还是建议你在项目的conftest.py里做一层自定义封装。原因很简单:现成fixture确实方便,但它默认是全自动的,你不好插入自己的前后置逻辑,比如登录、初始化数据库、记录日志。
一个比较稳的conftest.py写法是:
import pytest from playwright.sync_api import sync_playwright from config.settings import load_config @pytest.fixture(scope="session") def env_config(): return load_config() @pytest.fixture(scope="session") def browser(env_config): with sync_playwright() as p: browser = p.chromium.launch( headless=env_config["headless"], args=["--disable-blink-features=AutomationControlled"] ) yield browser browser.close() @pytest.fixture() def page(browser): context = browser.new_context( viewport={"width": 1920, "height": 1080}, ignore_https_errors=True ) page = context.new_page() page.set_default_timeout(15000) yield page context.close()scope="session"的browser只启动一次,所有用例复用同一个浏览器实例,但每个用例创建独立的context和page,相当于一个隔离的用户会话。这么做的好处是速度快、隔离好。这里注意set_default_timeout(15000),把默认超时设置成15秒,很多元素加载慢的场景都够用,又不会让失败用例等太久。
如果你有登录态的需求,比如大部分用例需要登录后才能操作,我建议建一个authed_page fixture,在page fixture的基础上执行登录操作,然后yield出来。这样绝大多数用例都不需要关心登录逻辑,只有未登录场景的用例才单独用page fixture。
3.3 用pytest-playwright还是自己封装?我的取舍
这个问题我在团队里被问过很多次。pytest-playwright提供的fixture确实开箱即用,但它太“官方模板化”了,不太适合复杂业务。比如你要在失败时自动截图、附加视频、打印请求日志、跳过登录步骤,这些都需要自己写逻辑。所以我最终的方案是:引用pytest-playwright作为依赖,但只把它当底层库用,fixture全在自己的conftest里重写。
这样做的好处是整个框架的执行逻辑完全透明可控,坏处是你要多写一点代码。但对于一个要长期维护的框架来说,可控性比省几行代码重要得多。另外,pytest-playwright版本升级时,你自定义的fixture不受影响,不会因为插件内部变化导致项目挂掉。我的建议是,新手可以先直接用官方fixture跑通流程,等要对框架做定制的时候,再逐步替换成自己的实现。
4. 实操:从浏览器启动到第一个可复用用例
光说不练假把式。这一节我会完整走一遍从浏览器启动到跑通业务用例的过程,包括一些运行命令、参数说明和现场记录。
4.1 第一步:把最基础的登录场景跑通
假设我们要测一个后台管理系统的登录。登录页有用户名输入框、密码输入框、登录按钮,登录成功后页面上会显示“欢迎回来”的提示。最简单的用例长这样:
# test_cases/test_login.py from pages.login_page import LoginPage from pages.dashboard_page import DashboardPage def test_login_with_valid_credentials(page, env_config): login_page = LoginPage(page) login_page.go_to(env_config["base_url"]) login_page.login(env_config["admin_user"], env_config["admin_password"]) dashboard_page = DashboardPage(page) assert dashboard_page.get_welcome_text() == "欢迎回来"注意用例里完全没有出现任何元素定位或浏览器的细节,登录动作被封装在LoginPage的login方法中,断言也通过DashboardPage暴露的方法来做。这就是PageObject的好处:用例读起来像业务描述一样。
LoginPage的简化实现里需要注意一个细节:登录成功后,页面会经过一个跳转和加载过程,如果立刻去定位Dashboard页的元素,可能会扑空。所以我通常在login方法最后会调用page.wait_for_url或者等待某一个登录后独有的元素出现,这样再返回的时候,页面状态就是稳定的。不要试图用一个固定的time.sleep(5)糊弄,加载慢的时候5秒不够,快的时候浪费时间,Playwright的自动等待就是为这个场景设计的。
4.2 第二步:参数化驱动多账号多环境数据
UI测试里登录场景最值得参数化。比如:
- 正确的用户名密码,登录成功
- 错误的密码,提示“用户名或密码错误”
- 用户名为空,提示“请输入用户名”
- 密码为空,提示“请输入密码”
- 账号被锁定,提示“账号已锁定”
用pytest的@pytest.mark.parametrize可以一次性贴上这些数据,一个用例函数跑五遍。
@pytest.mark.parametrize("username,password,expected_message", [ ("admin", "correct_password", "登录成功"), ("admin", "wrong_password", "用户名或密码错误"), ("", "123456", "请输入用户名"), ("admin", "", "请输入密码"), ("locked_user", "123456", "账号已锁定"), ]) def test_login_scenarios(page, env_config, username, password, expected_message): login_page = LoginPage(page) login_page.go_to(env_config["base_url"]) login_page.login(username, password) assert login_page.get_message_text() == expected_message这种写法比复制粘贴五个用例要干净得多。但这里有坑:如果前面一个参数组合已经登录成功,页面上可能已经处于登录态,下一个参数组合就要先清除cookie或者重新打开一个新的context。所以我在page fixture里每次创建新context,每个用例天然是干净的浏览器环境,避免用例间数据污染。
4.3 第三步:失败自动截图与视频留存
UI自动化用例失败后,最痛苦的事情是你看不到它失败时候页面长什么样。所以截图和视频录制是必备的。我习惯在conftest.py里做一个自动截图的操作:
@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: timestamp = time.strftime("%Y%m%d_%H%M%S") page.screenshot(path=f"reports/screenshots/{item.name}_{timestamp}.png")这段代码的作用是:不管用例失败,只要执行到call阶段,就自动从fixture里拿page对象,截图保存到固定目录。注意这里要确保目录存在,可以在conftest里用os.makedirs(..., exist_ok=True)提前创建。
如果你还想保留视频,Playwright的context是可以开启record_video_dir的。在创建context的时候传入record_video_dir="reports/videos",浏览器操作过程会被录制成webm文件。这个功能在调试复杂问题时特别管用,它能回放整个过程,定位到底是哪一步交互出了问题。
4.4 第四步:与CI流水线集成的几个小坑
框架写完之后,最终目标通常是挂到流水线上自动跑。这里我要提到三个常见坑。
第一个是无头模式。CI环境里没有显示器,Playwright要运行在headless模式下,也就是不打开浏览器窗口。通常在配置环境里切换,而不是硬编码。比如本地调试用有头模式,CI地用无头模式。
第二个是屏幕分辨率。很多Web页面的布局和按钮的可见性跟视口尺寸强相关。我在CI里把浏览器视口设置为1920x1080,同时在具体用例里不依赖滚动可见性,但还是遇到过一个“按钮不在视口内点击失败”的问题,最后用locator.scroll_into_view_if_needed()解决。这个API是Playwright专门为这类场景准备的。
第三个是重试与并行。CI流水线里我通常只会让pytest-xdist并行到2到4个进程,因为UI自动化对CPU和内存的占用是真的高,盲目堆worker数量会导致资源争抢、用例大量超时。失败重试建议设置在1次,不要太多,否则会掩盖真实问题,排查起来也困难。
5. 常见问题与排查技巧实录
最后这部分,我把这几个月里摸爬滚打遇到的问题整理成速查表,每个问题的排查思路和解决方案都是我实际跑过、验证过的。
5.1 元素明明存在,点击却报timeout
这个坑我在早期频繁遇到。现象是定位器能找到元素,点击的时候却一直超时。后来我发现原因有三类:第一,元素被遮住了,比如有遮罩层或者弹窗在页面上方;第二,元素其实在DOM里但不可见,比如父容器有隐藏样式;第三,元素处于“正在变化”的状态,每次定位都不一样。
排查方法很简单,在headless模式下加一行:page.pause(),或者打开headed模式仔细观察。更高效的做法是用Playwright的inspector工具,设置环境变量PWDEBUG=1,然后运行用例,它会自动打开一个调试窗口,帮你看到元素匹配情况和操作过程。
解决手段:如果元素可能被遮挡,优先使用element.click(force=True),但要谨慎,它会跳过可操作性检查,可能掩盖真实问题;更合理的是用element.scroll_into_view_if_needed()把元素滚进视口,再执行点击。
5.2 环境切换导致用例串数据
我们有个项目在测试环境和预发环境之间共用一套代码,结果某些用例在预发环境执行时会用到测试环境的过期数据,导致断言失败。排查时发现,用例在使用数据前没有重新读取配置,有些常量还是模块加载时缓存的。
解决方案:强制所有环境相关数据在fixture阶段动态读取,不要定义为模块级常量。还有一点,登录后的会话状态cookie默认是绑定域名的,环境切换后,旧的context不再使用,避免串数据。
5.3 并发执行时浏览器资源占用过高
用pytest-xdist并行跑的时候,每个worker都有自己独立的浏览器进程。4个worker跑起来,加上系统本身的服务,内存一下子吃紧。如果并发数量设置不当,还会出现“浏览器启动失败,超时”之类的问题。
我的调整方案:限制并发数最多为物理核数的一半,同时在browser启动参数里加上--disable-dev-shm-usage。这个参数在容器环境里尤其重要,因为默认的/dev/shm分区太小,多浏览器进程时会直接崩掉。另外,每个worker跑完用例后,确保context和browser都被关闭,不要留在后台占用资源。
5.4 报告与日志为什么总是对不上时间
我刚开始用pytest-xdist并行跑的时候,看Allure报告里的用例执行时间特别乱。排查后发现是多个worker同时写日志文件,行与行的时间顺序被打乱了。这个问题不解决,排查问题的时候非常痛苦。
我的做法是:日志文件名里加入worker标识符,/reports/logs/worker-{worker_id}.log,这样每个进程的日志独立成文件,看的时候按用例名称检索。Allure报告本身不依赖时间线,它按用例组织,所以报告里的执行顺序是准的,但日志文件要分开。另外,有时报错信息里包含时间戳,如果系统时钟不同步,也会造成误导,我后来统一在容器启动时用同一个时钟源,这个坑才算彻底解决。
5.5 自动等待与隐式等待的冲突
有些团队的旧代码会习惯性在脚本开头写上page.set_default_timeout或者其它等待逻辑。但Playwright其实推荐的做法是依赖locator内部自动等待,不要手动加全局sleep。我自己在实际项目中把显式sleep全部清掉,统一用locator的等待机制。结果是执行速度快了接近三分之一,用例稳定性不降反升。记住一个原则:能用expect(locator).to_be_visible()写明条件,就不要用sleep碰运气。
说实话,我很少把一套框架从头到尾讲得这么细,因为实操中的体验只有亲手撞过墙才知道。Playwright + pytest这套组合让我最满意的地方,就是它把“写用例”这件事重新变回了“描述业务场景”,而不是“伺候浏览器”。如果你正准备搭框架,希望这一套拆解能帮你少走一些我曾经走过的弯路。真正动手跑起来之后,遇到奇怪的问题,先想想是不是等待没用好、配置没隔离、并发调太高,大部分坑都能绕过去。