AI真好玩系列-Agent Skill深度调研⑰playwright | AI驱动的浏览器自动化测试神器

65 阅读24分钟

AI真好玩系列-Agent Skill深度调研⑰playwright | AI驱动的浏览器自动化测试神器

@[TOC]( AI真好玩系列-Agent Skill深度调研⑰playwright | AI驱动的浏览器自动化测试神器)

开头碎碎念

宝宝们又见面啦~👋 今天给大家带来一个重量级的 Agent Skill——playwright!

你有没有经历过这种崩溃时刻:辛辛苦苦写了一堆 Selenium 脚本,本地跑得好好的,一到 CI 环境就各种超时、元素找不到、页面没加载完...你盯着那个红色的 FAILED 发呆,心想"明明昨天还能跑的啊" 😅

更惨的是,老板说"我们的应用要兼容 Chrome、Firefox 和 Safari",你看了看 Selenium 的跨浏览器配置,再看了看那一堆 driver 版本兼容问题,内心OS:我直接辞职行不行?😭

还有更离谱的——测试脚本里到处都是 time.sleep(3),你也不知道为什么要等3秒,可能是2秒不够,5秒又太慢,反正3秒"差不多"能跑过。结果有一天服务器慢了一点,3秒又不够了...这就是传说中的 flaky test!😤

现在有了 playwright Skill,AI Agent 可以直接帮你写测试、跑测试、截图对比、模拟移动设备——你说"测试一下登录功能",它就帮你写好完整的端到端测试;你说"截个图看看页面长啥样",它直接给你截图;你说"模拟 iPhone 看看响应式效果",它立马给你模拟出来!这才是浏览器自动化该有的样子!🚀

废话不多说,让我带你从原理到实战,彻底搞懂这个 AI 驱动的浏览器自动化测试神器~ 🎯

🌟 项目简介 | Project Introduction

playwright 是微软开源的浏览器自动化测试框架,作为 Agent Skill 使用时,它让 AI Agent 能够进行端到端测试、浏览器自动化操作、网页截图、网络拦截、移动设备模拟等。它支持 Chromium、Firefox 和 WebKit 三大浏览器引擎,内置自动等待机制,彻底告别 flaky 测试。

  • 核心定位:AI Agent 的浏览器自动化能力引擎
  • 开发团队:微软(Microsoft)
  • 开源协议:Apache-2.0
  • 支持语言:Python / JavaScript / TypeScript / Java / .NET
  • 浏览器引擎:Chromium / Firefox / WebKit
  • 适用场景:端到端测试、浏览器自动化、网页截图、爬虫、UI验证

核心特性一览

特性说明
多浏览器引擎一套代码跑 Chromium/Firefox/WebKit 三大引擎
自动等待内置智能等待,无需手动 sleep
网络拦截拦截和修改网络请求,Mock API 响应
移动设备模拟模拟 iPhone/iPad/Android 等设备
截图与录屏页面截图、元素截图、录制测试视频
并行执行多 Worker 并行跑测试,速度飞快
追踪查看器测试失败时生成完整追踪,方便调试
代码生成录制用户操作自动生成测试代码
选择器引擎CSS/XPath/text/role 等多种选择器
浏览器上下文隔离的浏览器上下文,无需清理 Cookie

🤔 为什么需要 playwright? | Why playwright?

痛点场景还原

痛点1:Selenium 的 flaky test 让人崩溃

你:测试脚本本地跑过了,提交代码!
CI:❌ FAILED - Element not found
你:??我本地明明能跑过啊
你:加个 sleep(3) 试试
CI:✅ PASSED
你(第二天):提交代码!
CI:❌ FAILED - Timeout after 30000ms
你:???3秒不够了?
你:改成 sleep(5) 吧...
你(内心):这不是测试,这是玄学 😭

痛点2:跨浏览器测试是噩梦

老板:我们的网站要兼容 Chrome、Firefox 和 Safari
你:好的,用 Selenium 配一下
你:ChromeDriver 版本要和 Chrome 版本匹配...
你:Firefox 要用 geckodriver...
你:Safari 要开启远程自动化...
你:三个浏览器的 Driver 版本还不统一...
你:CI 环境里还要单独安装...
你:我能不能只测 Chrome?😅

痛点3:测试数据隔离困难

你:测试用例A需要登录状态
你:测试用例B需要未登录状态
你:测试用例C需要特定用户数据
你:每个用例跑完要清理 Cookie、localStorage...
你:清理不干净就会影响下一个用例...
你:并行跑的时候更惨,互相干扰...
你:算了,串行跑吧,等1个小时 😭

痛点4:移动端测试成本高

老板:测试一下手机端的响应式效果
你:买个 iPhone 测一下?
你:买个 Android 测一下?
你:买个 iPad 测一下?
你:买个各种尺寸的设备...
你:我能不能用 Chrome DevTools 的模拟?
你:可以,但是不能自动化啊...
你:手动测一遍要2小时,每周都要测... 😭

对比表格

问题SeleniumCypressplaywright Skill
跨浏览器需配置Driver仅Chromium三引擎开箱即用 ✔️
自动等待手动sleep部分自动全自动智能等待 ✔️
测试隔离手动清理有限浏览器上下文隔离 ✔️
移动模拟需Appium不支持内置设备模拟 ✔️
网络拦截需Proxy内置内置Route API ✔️
执行速度快 ✔️
并行执行需Grid内置内置Worker ✔️
调试能力有限Time TravelTrace Viewer ✔️
AI集成Agent Skill ✔️
学习成本低(自然语言) ✔️

一句话总结:**playwright 让浏览器自动化从"玄学调试"变成"科学测试"!**🔬

📌 前提条件 | Prerequisites

  1. Node.js 环境:Node.js 18+(JavaScript/TypeScript 版本)或 Python 3.8+(Python 版本)
  2. 操作系统:Windows / macOS / Linux 均支持
  3. 浏览器引擎:playwright 安装时自动下载,无需手动配置
  4. Agent 框架:Claude Code / OpenClaw / Codex CLI 等任一平台
  5. 基础概念:了解 DOM 结构、CSS 选择器、HTTP 请求响应

🚀 核心技术栈 | Core Technologies

技术版本用途链接
playwright1.48+浏览器自动化框架playwright.dev
Chromium最新Chrome/Edge 浏览器引擎chromium.org
Firefox最新Firefox 浏览器引擎mozilla.org
WebKit最新Safari 浏览器引擎webkit.org
pytest8.0+Python 测试框架pytest.org
pytest-playwright0.5+Playwright pytest 插件pypi.org/project/pyt…

🧩 核心原理详解 | Core Principles

1. 多浏览器引擎架构 | Multi-Browser Engine Architecture

playwright 最核心的能力是统一了三大浏览器引擎的自动化接口。一套测试代码,可以在 Chromium、Firefox 和 WebKit 上无缝运行:

                    ┌─────────────────────┐
                    │   Playwright API     │
                    │  (统一自动化接口)      │
                    └──────────┬──────────┘
                               │
                    ┌──────────┴──────────┐
                    │   Playwright Core    │
                    │  (跨引擎调度层)        │
                    └──────────┬──────────┘
                               │
              ┌────────────────┼────────────────┐
              │                │                │
    ┌─────────┴──────┐ ┌──────┴───────┐ ┌──────┴───────┐
    │  Chromium      │ │  Firefox      │ │  WebKit       │
    │  (Chrome/Edge) │ │  (Firefox)    │ │  (Safari)     │
    │  CDP协议       │ │  自定义协议    │ │  自定义协议    │
    └────────────────┘ └──────────────┘ └──────────────┘
# 多浏览器引擎测试——一套代码跑三个浏览器

import pytest
from playwright.sync_api import Browser, Page, BrowserContext


# ===== 方式1:通过 pytest 参数化自动跑三个浏览器 =====

# pytest.ini 配置:
# [pytest]
# addopts = --browser chromium --browser firefox --browser webkit

# 测试文件无需修改,pytest-playwright 自动参数化
def test_login_on_all_browsers(page: Page):
    """在三个浏览器上测试登录功能"""
    # 导航到登录页
    page.goto("https://example.com/login")

    # 填写用户名和密码
    page.fill('input[name="username"]', "testuser")
    page.fill('input[name="password"]', "password123")

    # 点击登录按钮
    page.click('button[type="submit"]')

    # 验证登录成功——检查是否跳转到首页
    page.wait_for_url("**/dashboard")
    assert page.title() == "Dashboard - Example App"


# ===== 方式2:手动指定浏览器引擎 =====

from playwright.sync_api import sync_playwright


def test_cross_browser_manually():
    """手动控制三个浏览器引擎"""
    with sync_playwright() as p:
        # --- Chromium 引擎 ---
        # 适用于:Chrome、Edge、Brave 等基于 Chromium 的浏览器
        chromium = p.chromium.launch(headless=False)
        chromium_page = chromium.new_page()
        chromium_page.goto("https://example.com")
        print(f"Chromium 标题: {chromium_page.title()}")
        chromium_page.screenshot(path="chromium_screenshot.png")
        chromium.close()

        # --- Firefox 引擎 ---
        # 适用于:Firefox 浏览器
        firefox = p.firefox.launch(headless=False)
        firefox_page = firefox.new_page()
        firefox_page.goto("https://example.com")
        print(f"Firefox 标题: {firefox_page.title()}")
        firefox_page.screenshot(path="firefox_screenshot.png")
        firefox.close()

        # --- WebKit 引擎 ---
        # 适用于:Safari 浏览器(macOS/iOS 的默认引擎)
        webkit = p.webkit.launch(headless=False)
        webkit_page = webkit.new_page()
        webkit_page.goto("https://example.com")
        print(f"WebKit 标题: {webkit_page.title()}")
        webkit_page.screenshot(path="webkit_screenshot.png")
        webkit.close()


# ===== 方式3:浏览器上下文隔离——同一浏览器多用户并行 =====

def test_browser_context_isolation():
    """演示浏览器上下文隔离——互不干扰"""
    with sync_playwright() as p:
        browser = p.chromium.launch()

        # 用户A的上下文——已登录状态
        context_a = browser.new_context()
        page_a = context_a.new_page()

        # 用户B的上下文——未登录状态
        context_b = browser.new_context()
        page_b = context_b.new_page()

        # 用户A登录
        page_a.goto("https://example.com/login")
        page_a.fill('input[name="username"]', "user_a")
        page_a.fill('input[name="password"]', "pass_a")
        page_a.click('button[type="submit"]')

        # 用户B访问同一网站——不会看到用户A的登录状态
        page_b.goto("https://example.com")
        # 用户B看到的是未登录的首页,而不是用户A的Dashboard
        assert "Sign In" in page_b.text_content("body")

        # 清理——每个上下文独立,互不影响
        context_a.close()
        context_b.close()
        browser.close()


# ===== 方式4:连接已有浏览器实例 =====

def test_connect_existing_browser():
    """连接到已运行的浏览器——复用登录状态"""
    with sync_playwright() as p:
        # 连接到通过 CDP 暴露的 Chrome 实例
        # 启动命令:chrome --remote-debugging-port=9222
        browser = p.chromium.connect_over_cdp("http://localhost:9222")

        # 获取已有上下文(包含你的登录状态)
        context = browser.contexts[0]
        page = context.new_page()

        # 直接访问需要登录的页面——无需重新登录
        page.goto("https://example.com/dashboard")
        print(f"已登录页面标题: {page.title()}")

        browser.close()
// JavaScript/TypeScript 版本——多浏览器引擎测试

const { test, expect } = require('@playwright/test');

// playwright.config.js 中配置多浏览器
// use: {
//   browserName: ['chromium', 'firefox', 'webkit'],
// }

// 测试文件——自动在三个浏览器上运行
test('登录功能在所有浏览器上正常工作', async ({ page }) => {
    // 导航到登录页
    await page.goto('https://example.com/login');

    // 填写表单
    await page.fill('input[name="username"]', 'testuser');
    await page.fill('input[name="password"]', 'password123');

    // 点击登录
    await page.click('button[type="submit"]');

    // 验证跳转
    await page.waitForURL('**/dashboard');
    await expect(page).toHaveTitle(/Dashboard/);
});

// 手动指定浏览器引擎
test('Chromium 特有功能测试', async ({ browser }) => {
    const context = await browser.newContext();
    const page = await context.newPage();

    await page.goto('https://example.com');

    // Chromium 特有的功能,如 CDP 会话
    const cdpSession = await context.newCDPSession(page);
    await cdpSession.send('Network.enable');

    await context.close();
});

2. 自动等待机制 | Auto-Waiting Mechanism

playwright 最革命性的设计是内置了自动等待机制。在执行任何操作之前,playwright 会自动执行一系列可操作性检查(actionability checks),确保元素已经准备好被操作:

用户代码:page.click("#submit")
                    │
                    ▼
        ┌───────────────────────┐
        │  Actionability Checks │
        │  (可操作性检查)         │
        └───────────┬───────────┘
                    │
    ┌───────────────┼───────────────┐
    │               │               │
    ▼               ▼               ▼
┌────────┐   ┌──────────┐   ┌──────────┐
│ 元素   │   │ 元素     │   │ 元素     │
│ 已附加 │   │ 可见     │   │ 稳定     │
│ 到DOM  │   │ 不被遮挡 │   │ 不在动画 │
└────────┘   └──────────┘   └──────────┘
    │               │               │
    ▼               ▼               ▼
┌────────┐   ┌──────────┐   ┌──────────┐
│ 元素   │   │ 元素     │   │ 元素     │
│ 已启用 │   │ 可接收   │   │ ...更多  │
│ 非禁用 │   │ 事件     │   │ 检查项   │
└────────┘   └──────────┘   └──────────┘
                    │
                    ▼
            ┌───────────────┐
            │  执行点击操作  │
            └───────────────┘
# 自动等待机制详解

from playwright.sync_api import sync_playwright, Page


# ===== 对比:Selenium vs Playwright =====

# Selenium 的做法——手动等待,容易出问题
"""
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

driver = webdriver.Chrome()
driver.get("https://example.com")

# 方式1:硬编码等待——不靠谱
import time
time.sleep(3)  # 等多久?看运气...
driver.find_element(By.ID, "submit").click()

# 方式2:显式等待——好一点但很啰嗦
wait = WebDriverWait(driver, 10)
element = wait.until(EC.element_to_be_clickable((By.ID, "submit")))
element.click()

# 方式3:隐式等待——全局生效,不够灵活
driver.implicitly_wait(5)
"""


# Playwright 的做法——自动等待,零配置
def test_auto_waiting():
    """playwright 自动等待——无需手动 sleep"""
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()

        page.goto("https://example.com")

        # playwright 自动等待:
        # 1. 元素已附加到 DOM
        # 2. 元素可见(不被遮挡)
        # 3. 元素稳定(不在动画中)
        # 4. 元素已启用(非 disabled)
        # 5. 元素可接收事件
        # 所有条件满足后才执行点击
        page.click("#submit")  # 就这么简单!无需手动等待

        # 自动等待也适用于 fill 操作
        page.fill("#username", "testuser")  # 自动等待输入框可用

        # 自动等待也适用于导航
        page.click("a[href='/about']")  # 自动等待链接可点击
        # 点击后自动等待导航完成

        browser.close()


# ===== 自定义等待条件 =====

def test_custom_waiting():
    """自定义等待条件——灵活控制等待逻辑"""
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.goto("https://example.com")

        # 方式1:等待特定元素出现
        page.wait_for_selector(".loaded", state="visible")

        # 方式2:等待导航完成
        page.wait_for_url("**/dashboard")

        # 方式3:等待网络请求完成
        page.wait_for_load_state("networkidle")  # 网络空闲

        # 方式4:等待自定义条件
        page.wait_for_function("""
            // 等待页面上的数据加载完成
            () => document.querySelectorAll('.data-row').length > 0
        """)

        # 方式5:等待特定网络响应
        with page.expect_response("**/api/data") as response_info:
            page.click("#load-data")
        response = response_info.value
        assert response.status == 200

        # 方式6:等待弹窗出现
        with page.expect_popup() as popup_info:
            page.click("#open-popup")
        popup = popup_info.value
        popup.wait_for_load_state()

        browser.close()


# ===== 超时配置 =====

def test_timeout_configuration():
    """超时配置——灵活控制等待时间"""
    with sync_playwright() as p:
        # 全局超时配置
        browser = p.chromium.launch()
        context = browser.new_context()
        page = context.new_page()

        # 设置默认导航超时(毫秒)
        page.set_default_navigation_timeout(30000)  # 30秒

        # 设置默认超时(毫秒)
        page.set_default_timeout(10000)  # 10秒

        # 单次操作自定义超时
        page.goto("https://example.com", timeout=60000)  # 这次等60秒
        page.click("#submit", timeout=5000)  # 这次只等5秒

        browser.close()


# ===== 重试机制 =====

def test_retry_mechanism():
    """playwright 的重试机制——自动重试失败的操作"""
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()

        page.goto("https://example.com")

        # playwright 内部会自动重试操作
        # 如果元素暂时不可用,会重试直到超时
        # 比如元素正在动画中,会等动画结束后再操作
        page.click(".animated-button")  # 自动等待动画结束

        # 对于断言,playwright 也提供了自动重试
        from playwright.sync_api import expect

        # expect 会自动重试断言,直到超时
        # 不需要手动写 while 循环
        expect(page.locator(".status")).to_have_text("Success", timeout=10000)

        browser.close()
# 自动等待的完整可操作性检查列表

"""
playwright 在执行操作前自动执行的检查项:

1. Attached(已附加)
   - 元素必须已附加到 DOM 树
   - 不在 detached 状态

2. Visible(可见)
   - 元素必须有非零尺寸
   - 不被 display:none 隐藏
   - 不被 visibility:hidden 隐藏
   - 不被其他元素完全遮挡

3. Stable(稳定)
   - 元素不在动画中
   - 元素位置和尺寸不再变化
   - 至少连续两帧保持不动

4. Enabled(已启用)
   - 元素不是 disabled 状态
   - button、input 等表单元素可交互

5. Receives Events(可接收事件)
   - 元素是事件的目标
   - 没有其他元素覆盖在上面拦截点击
   - 比如透明的 overlay 不会阻止点击

6. Editable(可编辑)—— 仅 fill 操作
   - 元素不是 readonly
   - 元素不是 disabled

这些检查全部通过后,playwright 才会执行操作。
如果超时未通过,则抛出 TimeoutError。
"""

# 实际案例:对比 Selenium 和 Playwright 处理动态加载

def test_dynamic_loading_comparison():
    """对比 Selenium 和 Playwright 处理动态加载"""
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()

        # 场景:点击按钮后,数据通过 AJAX 加载
        page.goto("https://example.com/data")

        # 点击加载数据按钮
        page.click("#load-data")

        # Selenium 的做法:
        # time.sleep(5)  # 等多久?不知道...
        # 或者
        # wait.until(EC.presence_of_element_located((By.ID, "data-table")))

        # Playwright 的做法:
        # 自动等待数据表格出现
        page.click("#data-table .first-row")  # 自动等待表格出现并可点击

        # 或者更精确地等待
        page.wait_for_selector("#data-table", state="attached")
        # state 选项:
        # - "attached":元素已附加到 DOM(可能不可见)
        # - "visible":元素可见
        # - "hidden":元素隐藏或不存在
        # - "detached":元素已从 DOM 移除

        browser.close()

3. 网络拦截 | Network Interception

playwright 提供了强大的网络拦截能力,可以拦截、修改、Mock 网络请求和响应,这在测试中极为有用:

# 网络拦截详解

from playwright.sync_api import sync_playwright, Page, Route


# ===== 基础请求拦截 =====

def test_intercept_requests():
    """拦截和修改网络请求"""
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()

        # --- 拦截所有请求 ---
        def handle_route(route: Route):
            """请求拦截处理器"""
            # 打印请求信息
            print(f"请求: {route.request.method} {route.request.url}")

            # 继续发送请求(可以修改请求参数)
            route.continue_()

        # 注册拦截器——拦截所有请求
        page.route("**/*", handle_route)

        page.goto("https://example.com")

        # --- 拦截特定请求 ---
        # 只拦截 API 请求
        page.route("**/api/**", lambda route: route.continue_())

        # --- 修改请求头 ---
        def add_auth_header(route: Route):
            """给请求添加认证头"""
            headers = route.request.headers
            headers["Authorization"] = "Bearer test-token-123"
            route.continue_(headers=headers)

        page.route("**/api/**", add_auth_header)

        # --- 修改请求体 ---
        def modify_request_body(route: Route):
            """修改 POST 请求体"""
            if route.request.method == "POST":
                # 获取原始请求体
                original_body = route.request.post_data
                print(f"原始请求体: {original_body}")

                # 修改请求体
                new_body = '{"username": "mock_user", "password": "mock_pass"}'
                route.continue_(post_data=new_body)
            else:
                route.continue_()

        page.route("**/api/login", modify_request_body)

        # --- 阻止请求 ---
        # 阻止加载图片——加速测试
        page.route("**/*.{png,jpg,jpeg,gif,svg,webp}", lambda route: route.abort())

        # 阻止加载字体
        page.route("**/*.{woff,woff2,ttf,otf}", lambda route: route.abort())

        # 阻止第三方追踪脚本
        page.route("**/analytics/**", lambda route: route.abort())
        page.route("**/tracking/**", lambda route: route.abort())

        page.goto("https://example.com")

        browser.close()


# ===== Mock API 响应 =====

def test_mock_api_responses():
    """Mock API 响应——无需真实后端"""
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()

        # --- Mock 登录 API ---
        def mock_login(route: Route):
            """Mock 登录接口返回成功"""
            if "login" in route.request.url:
                route.fulfill(
                    status=200,
                    content_type="application/json",
                    body='{"success": true, "token": "mock-jwt-token", "user": {"name": "测试用户"}}',
                )
            else:
                route.continue_()

        page.route("**/api/auth/**", mock_login)

        # --- Mock 用户数据 API ---
        def mock_user_data(route: Route):
            """Mock 用户数据接口"""
            route.fulfill(
                status=200,
                content_type="application/json",
                body="""{
                    "users": [
                        {"id": 1, "name": "张三", "email": "zhangsan@example.com"},
                        {"id": 2, "name": "李四", "email": "lisi@example.com"},
                        {"id": 3, "name": "王五", "email": "wangwu@example.com"}
                    ],
                    "total": 3,
                    "page": 1
                }""",
            )

        page.route("**/api/users", mock_user_data)

        # --- Mock 错误响应 ---
        def mock_server_error(route: Route):
            """Mock 服务器错误——测试错误处理"""
            route.fulfill(
                status=500,
                content_type="application/json",
                body='{"error": "Internal Server Error", "message": "服务器开小差了"}',
            )

        page.route("**/api/error", mock_server_error)

        # --- Mock 延迟响应 ---
        def mock_slow_api(route: Route):
            """Mock 慢速 API——测试加载状态"""
            import time
            time.sleep(3)  # 模拟3秒延迟
            route.fulfill(
                status=200,
                content_type="application/json",
                body='{"data": "slow response"}',
            )

        page.route("**/api/slow", mock_slow_api)

        # --- Mock 文件下载 ---
        def mock_file_download(route: Route):
            """Mock 文件下载"""
            route.fulfill(
                status=200,
                content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
                headers={"Content-Disposition": "attachment; filename=report.xlsx"},
                body=b"mock-excel-file-content",
            )

        page.route("**/api/download/report", mock_file_download)

        page.goto("https://example.com")

        browser.close()


# ===== 高级网络拦截 =====

def test_advanced_network_interception():
    """高级网络拦截——请求修改、响应篡改"""
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()

        # --- 拦截并修改响应 ---
        def modify_response(route: Route):
            """拦截真实响应并修改内容"""
            # 先获取真实响应
            response = route.fetch()

            # 修改响应体
            body = response.text()
            # 替换页面中的某些文字
            modified_body = body.replace("原价", "限时优惠价")
            modified_body = modified_body.replace("¥999", "¥599")

            # 返回修改后的响应
            route.fulfill(
                response=response,
                body=modified_body,
            )

        page.route("**/product/**", modify_response)

        # --- 录制网络请求 ---
        requests_log = []

        def log_requests(route: Route):
            """记录所有网络请求"""
            requests_log.append({
                "method": route.request.method,
                "url": route.request.url,
                "headers": dict(route.request.headers),
                "post_data": route.request.post_data,
            })
            route.continue_()

        page.route("**/*", log_requests)

        # --- 条件性拦截 ---
        def conditional_intercept(route: Route):
            """根据条件决定是否拦截"""
            url = route.request.url

            # 生产环境 API 转发到测试环境
            if "api.production.com" in url:
                new_url = url.replace("api.production.com", "api.staging.com")
                route.continue_(url=new_url)
            # 第三方追踪脚本直接阻止
            elif any(domain in url for domain in ["google-analytics", "facebook.net", "doubleclick"]):
                route.abort()
            else:
                route.continue_()

        page.route("**/*", conditional_intercept)

        page.goto("https://example.com")

        # 查看录制的请求
        for req in requests_log:
            print(f"{req['method']} {req['url']}")

        browser.close()


# ===== HAR 文件录制和回放 =====

def test_har_recording():
    """HAR 文件录制和回放——完美复现网络行为"""
    with sync_playwright() as p:
        browser = p.chromium.launch()

        # --- 录制 HAR ---
        context = browser.new_context(
            record_har_path="network.har"  # 录制到 HAR 文件
        )
        page = context.new_page()
        page.goto("https://example.com")
        page.click("#load-data")
        context.close()  # 关闭时自动保存 HAR 文件

        # --- 回放 HAR ---
        # 使用录制的 HAR 文件回放网络请求
        context_replay = browser.new_context(
            record_har_path=None,
        )
        page_replay = context_replay.new_page()

        # 用录制的 HAR 响应替代真实网络请求
        page_replay.route_from_har("network.har", not_found="abort")

        page_replay.goto("https://example.com")
        # 所有网络请求都从 HAR 文件中获取,无需真实网络

        browser.close()

4. 移动设备模拟 | Mobile Device Emulation

playwright 内置了丰富的移动设备模拟能力,可以模拟各种手机和平板的屏幕尺寸、用户代理、触摸事件等:

# 移动设备模拟详解

from playwright.sync_api import sync_playwright, BrowserContext


# ===== 预置设备模拟 =====

def test_mobile_emulation():
    """使用预置设备配置模拟移动端"""
    with sync_playwright() as p:
        # --- iPhone 14 Pro ---
        iphone = p.devices["iPhone 14 Pro"]
        context = browser.new_context(**iphone)
        page = context.new_page()
        page.goto("https://example.com")
        page.screenshot(path="iphone14pro.png")
        context.close()

        # --- iPhone 15 Pro Max ---
        iphone_max = p.devices["iPhone 15 Pro Max"]
        context = browser.new_context(**iphone_max)
        page = context.new_page()
        page.goto("https://example.com")
        page.screenshot(path="iphone15promax.png")
        context.close()

        # --- iPad Pro ---
        ipad = p.devices["iPad Pro 11"]
        context = browser.new_context(**ipad)
        page = context.new_page()
        page.goto("https://example.com")
        page.screenshot(path="ipadpro.png")
        context.close()

        # --- Pixel 7 ---
        pixel = p.devices["Pixel 7"]
        context = browser.new_context(**pixel)
        page = context.new_page()
        page.goto("https://example.com")
        page.screenshot(path="pixel7.png")
        context.close()

        # --- Galaxy S9+ ---
        galaxy = p.devices["Galaxy S9+"]
        context = browser.new_context(**galaxy)
        page = context.new_page()
        page.goto("https://example.com")
        page.screenshot(path="galaxy_s9.png")
        context.close()


# ===== 自定义设备模拟 =====

def test_custom_device_emulation():
    """自定义设备模拟参数"""
    with sync_playwright() as p:
        browser = p.chromium.launch()

        # --- 自定义移动设备 ---
        context = browser.new_context(
            # 屏幕尺寸
            viewport={"width": 375, "height": 812},

            # 设备像素比(Retina 屏幕为 2 或 3)
            device_scale_factor=3,

            # 是否模拟触摸
            has_touch=True,

            # 是否为移动端
            is_mobile=True,

            # 用户代理字符串
            user_agent="Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15",

            # 地理位置权限
            permissions=["geolocation"],
            geolocation={"latitude": 39.9042, "longitude": 116.4074},  # 北京

            # 语言和时区
            locale="zh-CN",
            timezone_id="Asia/Shanghai",

            # 颜色方案
            color_scheme="dark",  # 深色模式

            # 减少动画(辅助功能)
            reduced_motion="reduce",
        )

        page = context.new_page()
        page.goto("https://example.com")

        # 截图对比——移动端 vs 桌面端
        page.screenshot(path="mobile_custom.png")

        context.close()
        browser.close()


# ===== 触摸事件模拟 =====

def test_touch_events():
    """模拟触摸事件——滑动、缩放、长按"""
    with sync_playwright() as p:
        iphone = p.devices["iPhone 14 Pro"]
        browser = p.chromium.launch()
        context = browser.new_context(**iphone)
        page = context.new_page()

        page.goto("https://example.com")

        # --- 模拟滑动(下拉刷新) ---
        # 从屏幕中间向下滑动
        page.mouse.move(187, 400)  # 移动到屏幕中间
        page.mouse.down()
        page.mouse.move(187, 600, steps=10)  # 向下滑动200px
        page.mouse.up()

        # --- 模拟缩放(双指缩放) ---
        # Playwright 不直接支持双指缩放,但可以通过 JS 注入实现
        page.evaluate("""
            // 模拟双指缩放事件
            const element = document.querySelector('.zoomable');
            const event = new WheelEvent('wheel', {
                deltaY: -100,  // 负值=放大
                ctrlKey: true,  // Ctrl+滚轮=缩放
                bubbles: true,
            });
            element.dispatchEvent(event);
        """)

        # --- 模拟长按 ---
        # 长按500毫秒
        element = page.locator(".long-press-target")
        element.click(delay=500)  # 按住500毫秒后释放

        # --- 模拟滑动手势(使用 touch 接口) ---
        page.evaluate("""
            // 模拟左滑手势
            const target = document.querySelector('.swipeable');
            const touchStart = new TouchEvent('touchstart', {
                touches: [{ clientX: 300, clientY: 200 }],
                bubbles: true,
            });
            const touchEnd = new TouchEvent('touchend', {
                changedTouches: [{ clientX: 100, clientY: 200 }],
                bubbles: true,
            });
            target.dispatchEvent(touchStart);
            setTimeout(() => target.dispatchEvent(touchEnd), 200);
        """)

        context.close()
        browser.close()


# ===== 响应式测试矩阵 =====

def test_responsive_matrix():
    """响应式测试矩阵——一次测试多个屏幕尺寸"""
    with sync_playwright() as p:
        browser = p.chromium.launch()

        # 定义测试矩阵
        devices_config = [
            {"name": "iPhone SE", "width": 375, "height": 667, "scale": 2},
            {"name": "iPhone 14", "width": 390, "height": 844, "scale": 3},
            {"name": "iPad Mini", "width": 768, "height": 1024, "scale": 2},
            {"name": "iPad Pro", "width": 1024, "height": 1366, "scale": 2},
            {"name": "Desktop", "width": 1920, "height": 1080, "scale": 1},
        ]

        for device in devices_config:
            context = browser.new_context(
                viewport={"width": device["width"], "height": device["height"]},
                device_scale_factor=device["scale"],
                is_mobile=device["width"] < 768,
            )
            page = context.new_page()
            page.goto("https://example.com")

            # 截图保存
            filename = f"responsive_{device['name'].replace(' ', '_').lower()}.png"
            page.screenshot(path=filename, full_page=True)

            # 检查关键元素是否可见
            nav = page.locator("nav")
            assert nav.is_visible(), f"{device['name']}: 导航栏不可见"

            # 检查移动端汉堡菜单
            if device["width"] < 768:
                hamburger = page.locator(".mobile-menu-toggle")
                assert hamburger.is_visible(), f"{device['name']}: 移动端菜单按钮不可见"

            context.close()

        browser.close()
        print("响应式测试矩阵完成!已生成5个设备的截图")


# ===== 列出所有预置设备 =====

def list_all_devices():
    """列出 playwright 所有预置设备"""
    with sync_playwright() as p:
        devices = p.devices
        print(f"共 {len(devices)} 个预置设备:")
        print()

        # 按品牌分组
        brands = {}
        for name, config in devices.items():
            brand = name.split()[0] if " " in name else "Other"
            brands.setdefault(brand, []).append(name)

        for brand, device_names in sorted(brands.items()):
            print(f"【{brand}】({len(device_names)}个)")
            for name in sorted(device_names):
                print(f"  - {name}")
            print()


# 运行查看所有设备
list_all_devices()

🛠️ 安装与使用 | Installation & Usage

安装方式1:pip 安装(Python 版本)

# 安装 playwright Python 包
pip install playwright

# 安装浏览器引擎(首次使用必须执行)
playwright install

# 只安装特定浏览器引擎
playwright install chromium    # 只安装 Chromium
playwright install firefox     # 只安装 Firefox
playwright install webkit      # 只安装 WebKit

# 安装 pytest 插件
pip install pytest-playwright

# 验证安装
python -c "from playwright.sync_api import sync_playwright; print('Playwright Python OK!')"
playwright --version

安装方式2:npm 安装(JavaScript/TypeScript 版本)

# 创建项目目录
mkdir playwright-tests && cd playwright-tests

# 初始化项目
npm init -y

# 安装 playwright
npm init playwright@latest

# 交互式配置:
# ? Do you want to use TypeScript or JavaScript? TypeScript
# ? Where to put your end-to-end tests? tests
# ? Add a GitHub Actions workflow? true
# ? Install Playwright browsers (can be done manually via 'npx playwright install')? true

# 手动安装
npm install @playwright/test
npx playwright install

# 验证安装
npx playwright --version

安装方式3:Docker 一键部署

# 拉取官方 Docker 镜像(已包含浏览器引擎)
docker pull mcr.microsoft.com/playwright/python:v1.48.0-noble

# 运行测试
docker run -it --rm \
    -v $(pwd)/tests:/app/tests \
    -v $(pwd)/test-results:/app/test-results \
    mcr.microsoft.com/playwright/python:v1.48.0-noble \
    pytest /app/tests/ --headed

# JavaScript 版本
docker pull mcr.microsoft.com/playwright:v1.48.0-noble
docker run -it --rm \
    -v $(pwd)/tests:/app/tests \
    mcr.microsoft.com/playwright:v1.48.0-noble \
    npx playwright test

使用示例1:基础端到端测试 | Basic E2E Test

"""
示例1:基础端到端测试
场景:测试一个电商网站的登录和购物流程
"""

from playwright.sync_api import sync_playwright, expect


def test_shopping_flow():
    """完整的购物流程测试"""
    with sync_playwright() as p:
        # 启动浏览器(headless=False 可以看到浏览器操作)
        browser = p.chromium.launch(headless=False, slow_mo=500)
        page = browser.new_page()

        # ===== 步骤1:访问首页 =====
        page.goto("https://demo.playwright.dev/todomvc")
        page.wait_for_load_state("networkidle")

        # ===== 步骤2:添加待办事项 =====
        # 使用 fill + press 模拟用户输入
        page.fill(".new-todo", "买牛奶")
        page.press(".new-todo", "Enter")

        page.fill(".new-todo", "买面包")
        page.press(".new-todo", "Enter")

        page.fill(".new-todo", "买鸡蛋")
        page.press(".new-todo", "Enter")

        # ===== 步骤3:验证待办事项数量 =====
        todo_items = page.locator(".todo-list li")
        expect(todo_items).to_have_count(3)

        # ===== 步骤4:完成一个待办事项 =====
        page.locator(".todo-list li").nth(0).locator(".toggle").check()

        # 验证已完成数量
        completed = page.locator(".todo-list li.completed")
        expect(completed).to_have_count(1)

        # ===== 步骤5:筛选待办事项 =====
        page.click("text=Active")  # 只显示未完成
        expect(page.locator(".todo-list li")).to_have_count(2)

        page.click("text=Completed")  # 只显示已完成
        expect(page.locator(".todo-list li")).to_have_count(1)

        # ===== 步骤6:截图保存 =====
        page.screenshot(path="shopping_flow.png", full_page=True)

        browser.close()


# 运行命令:pytest test_shopping.py --headed

使用示例2:网页截图与PDF生成 | Screenshot & PDF

"""
示例2:网页截图与PDF生成
场景:生成网页截图和PDF报告
"""

from playwright.sync_api import sync_playwright


def test_screenshot_and_pdf():
    """网页截图和PDF生成"""
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()

        page.goto("https://example.com", wait_until="networkidle")

        # ===== 1. 全页面截图 =====
        page.screenshot(
            path="full_page.png",
            full_page=True,           # 截取完整页面(包括滚动区域)
            animations="disabled",     # 禁用动画,确保截图稳定
        )

        # ===== 2. 视口截图(只截可见区域) =====
        page.screenshot(path="viewport.png")  # 默认只截视口

        # ===== 3. 元素截图 =====
        # 截取特定元素的截图
        header = page.locator("header")
        header.screenshot(path="header.png")

        # 截取特定区域
        main_content = page.locator("main")
        main_content.screenshot(path="main_content.png")

        # ===== 4. 多设备截图对比 =====
        devices = [
            {"name": "desktop", "viewport": {"width": 1920, "height": 1080}},
            {"name": "tablet", "viewport": {"width": 768, "height": 1024}},
            {"name": "mobile", "viewport": {"width": 375, "height": 812}},
        ]

        for device in devices:
            context = browser.new_context(viewport=device["viewport"])
            device_page = context.new_page()
            device_page.goto("https://example.com", wait_until="networkidle")
            device_page.screenshot(
                path=f"screenshot_{device['name']}.png",
                full_page=True,
            )
            context.close()

        # ===== 5. 生成PDF(仅Chromium支持) =====
        page.pdf(
            path="page.pdf",
            format="A4",               # A4纸张
            print_background=True,      # 打印背景色
            margin={
                "top": "20mm",
                "right": "15mm",
                "bottom": "20mm",
                "left": "15mm",
            },
        )

        # ===== 6. 录制视频 =====
        context = browser.new_context(
            record_video_dir="videos/",       # 视频保存目录
            record_video_size={"width": 1280, "height": 720},
        )
        video_page = context.new_page()
        video_page.goto("https://example.com")
        video_page.click("a")
        video_page.fill("input", "test")
        context.close()  # 关闭时自动保存视频

        browser.close()


# ===== 批量截图工具 =====

def batch_screenshot(urls: list, output_dir: str = "screenshots"):
    """批量截图工具——给AI Agent用的截图能力"""
    import os
    os.makedirs(output_dir, exist_ok=True)

    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page(viewport={"width": 1920, "height": 1080})

        results = []
        for url in urls:
            try:
                page.goto(url, wait_until="networkidle", timeout=30000)
                # 生成安全的文件名
                safe_name = url.replace("https://", "").replace("http://", "")
                safe_name = safe_name.replace("/", "_").replace(".", "_")[:50]
                filepath = os.path.join(output_dir, f"{safe_name}.png")

                page.screenshot(path=filepath, full_page=True)
                results.append({"url": url, "status": "success", "path": filepath})
                print(f"✅ 截图成功: {url}")
            except Exception as e:
                results.append({"url": url, "status": "error", "error": str(e)})
                print(f"❌ 截图失败: {url} - {e}")

        browser.close()
        return results


# 使用示例
urls = [
    "https://playwright.dev",
    "https://example.com",
    "https://github.com/microsoft/playwright",
]
results = batch_screenshot(urls)

使用示例3:网络拦截与Mock | Network Mock

"""
示例3:网络拦截与Mock
场景:测试前端页面,Mock后端API响应
"""

from playwright.sync_api import sync_playwright, Route
import json


def test_with_mock_api():
    """使用 Mock API 测试前端页面"""
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()

        # ===== Mock 商品列表 API =====
        def mock_products(route: Route):
            """Mock 商品列表接口"""
            mock_data = {
                "products": [
                    {"id": 1, "name": "iPhone 15 Pro", "price": 8999, "stock": 100},
                    {"id": 2, "name": "MacBook Pro", "price": 14999, "stock": 50},
                    {"id": 3, "name": "AirPods Pro", "price": 1899, "stock": 200},
                ],
                "total": 3,
            }
            route.fulfill(
                status=200,
                content_type="application/json",
                body=json.dumps(mock_data),
            )

        page.route("**/api/products", mock_products)

        # ===== Mock 购物车 API =====
        def mock_cart(route: Route):
            """Mock 购物车接口"""
            if route.request.method == "GET":
                mock_data = {
                    "items": [
                        {"product_id": 1, "quantity": 2, "price": 8999},
                    ],
                    "total": 17998,
                }
                route.fulfill(
                    status=200,
                    content_type="application/json",
                    body=json.dumps(mock_data),
                )
            elif route.request.method == "POST":
                # 添加到购物车
                route.fulfill(
                    status=201,
                    content_type="application/json",
                    body='{"success": true, "message": "已添加到购物车"}',
                )

        page.route("**/api/cart/**", mock_cart)

        # ===== Mock 支付 API =====
        def mock_payment(route: Route):
            """Mock 支付接口"""
            route.fulfill(
                status=200,
                content_type="application/json",
                body='{"payment_id": "pay_mock_123", "status": "success", "redirect_url": "/order/success"}',
            )

        page.route("**/api/payment", mock_payment)

        # ===== 阻止不必要的请求(加速测试) =====
        page.route("**/*.{png,jpg,jpeg,gif,svg}", lambda route: route.abort())
        page.route("**/analytics/**", lambda route: route.abort())

        # 现在可以测试前端页面了,无需真实后端
        page.goto("https://example-shop.com")
        page.wait_for_load_state("networkidle")

        # 验证商品列表已加载
        products = page.locator(".product-item")
        # 因为 Mock 返回了3个商品,所以应该有3个商品元素

        browser.close()


# ===== API 测试工具 =====

def test_api_with_playwright():
    """使用 playwright 发送 API 请求"""
    from playwright.sync_api import APIRequestContext

    with sync_playwright() as p:
        # 创建 API 请求上下文
        api_context = p.request.new_context(
            base_url="https://api.example.com",
            extra_http_headers={"Authorization": "Bearer test-token"},
        )

        # GET 请求
        response = api_context.get("/users")
        assert response.ok
        users = response.json()
        print(f"获取到 {len(users)} 个用户")

        # POST 请求
        response = api_context.post("/users", data={
            "name": "测试用户",
            "email": "test@example.com",
        })
        assert response.ok
        new_user = response.json()
        print(f"创建用户: {new_user['id']}")

        # PUT 请求
        response = api_context.put(f"/users/{new_user['id']}", data={
            "name": "更新后的用户名",
        })
        assert response.ok

        # DELETE 请求
        response = api_context.delete(f"/users/{new_user['id']}")
        assert response.ok

        api_context.dispose()

使用示例4:移动端测试 | Mobile Testing

"""
示例4:移动端测试
场景:测试移动端网页的响应式布局和触摸交互
"""

from playwright.sync_api import sync_playwright, expect


def test_mobile_responsive():
    """移动端响应式测试"""
    with sync_playwright() as p:
        # ===== iPhone 14 Pro 测试 =====
        iphone_14 = p.devices["iPhone 14 Pro"]
        browser = p.chromium.launch()
        context = browser.new_context(**iphone_14)
        page = context.new_page()

        page.goto("https://example.com", wait_until="networkidle")

        # 验证移动端导航菜单
        # 桌面端的导航栏应该被隐藏
        desktop_nav = page.locator(".desktop-nav")
        expect(desktop_nav).to_be_hidden()

        # 移动端汉堡菜单应该可见
        mobile_menu = page.locator(".mobile-menu-toggle")
        expect(mobile_menu).to_be_visible()

        # 点击汉堡菜单
        mobile_menu.click()

        # 验证菜单展开
        mobile_nav = page.locator(".mobile-nav")
        expect(mobile_nav).to_be_visible()

        # 截图
        page.screenshot(path="mobile_iphone14.png")

        context.close()

        # ===== iPad 测试 =====
        ipad = p.devices["iPad Pro 11"]
        context = browser.new_context(**ipad)
        page = context.new_page()

        page.goto("https://example.com", wait_until="networkidle")

        # iPad 应该显示桌面端导航
        desktop_nav = page.locator(".desktop-nav")
        expect(desktop_nav).to_be_visible()

        page.screenshot(path="mobile_ipad.png")

        context.close()
        browser.close()


def test_mobile_touch_interactions():
    """移动端触摸交互测试"""
    with sync_playwright() as p:
        iphone = p.devices["iPhone 14 Pro"]
        browser = p.chromium.launch()
        context = browser.new_context(**iphone)
        page = context.new_page()

        page.goto("https://example.com", wait_until="networkidle")

        # ===== 下拉刷新 =====
        # 模拟从上往下的滑动手势
        page.mouse.move(187, 100)    # 移动到屏幕顶部
        page.mouse.down()
        page.mouse.move(187, 400, steps=20)  # 向下滑动
        page.mouse.up()

        # 等待刷新完成
        page.wait_for_selector(".refresh-indicator", state="hidden")

        # ===== 滑动轮播图 =====
        carousel = page.locator(".carousel")
        box = carousel.bounding_box()
        if box:
            # 从右向左滑动
            start_x = box["x"] + box["width"] * 0.8
            end_x = box["x"] + box["width"] * 0.2
            y = box["y"] + box["height"] / 2

            page.mouse.move(start_x, y)
            page.mouse.down()
            page.mouse.move(end_x, y, steps=10)
            page.mouse.up()

        # ===== 地理位置测试 =====
        # 授予地理位置权限
        context.grant_permissions(["geolocation"])
        context.set_geolocation({"latitude": 31.2304, "longitude": 121.4737})  # 上海

        page.goto("https://example.com/nearby")
        # 验证附近店铺列表已加载
        expect(page.locator(".nearby-store").first).to_be_visible()

        context.close()
        browser.close()

使用示例5:视觉回归测试 | Visual Regression Testing

"""
示例5:视觉回归测试
场景:对比页面截图,检测UI变化
"""

from playwright.sync_api import sync_playwright, expect


def test_visual_regression():
    """视觉回归测试——检测UI变化"""
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page(viewport={"width": 1280, "height": 720})

        page.goto("https://example.com", wait_until="networkidle")

        # ===== 全页面截图对比 =====
        expect(page).to_have_screenshot("homepage.png", {
            "max_diff_pixels": 100,       # 允许最多100个像素差异
            "threshold": 0.2,             # 差异阈值(0-1)
            "animations": "disabled",     # 禁用动画
        })

        # ===== 元素截图对比 =====
        header = page.locator("header")
        expect(header).to_have_screenshot("header.png", {
            "max_diff_pixels": 50,
        })

        # ===== 特定区域截图对比 =====
        hero_section = page.locator(".hero-section")
        expect(hero_section).to_have_screenshot("hero.png")

        browser.close()


# ===== 首次运行:生成基准截图 =====
# pytest test_visual.py --update-snapshots

# ===== 后续运行:对比基准截图 =====
# pytest test_visual.py

# ===== 如果有差异,会生成三张图片 =====
# - homepage.png          (当前截图)
# - homepage-expected.png (期望截图)
# - homepage-diff.png     (差异对比图,红色标记差异区域)


def test_visual_regression_with_mask():
    """带遮罩的视觉回归测试——忽略动态区域"""
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()

        page.goto("https://example.com", wait_until="networkidle")

        # 忽略动态内容区域(如广告、时间戳、随机推荐)
        expect(page).to_have_screenshot("homepage_masked.png", {
            "mask": [
                page.locator(".ad-banner"),       # 广告横幅
                page.locator(".timestamp"),       # 时间戳
                page.locator(".random-recommend"), # 随机推荐
            ],
            "max_diff_pixels": 100,
        })

        browser.close()


# ===== 多页面视觉回归测试 =====

def test_multi_page_visual_regression():
    """多页面视觉回归测试"""
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page(viewport={"width": 1280, "height": 720})

        # 定义需要测试的页面
        pages_to_test = {
            "homepage": "/",
            "about": "/about",
            "products": "/products",
            "contact": "/contact",
            "blog": "/blog",
        }

        for name, path in pages_to_test.items():
            page.goto(f"https://example.com{path}", wait_until="networkidle")
            expect(page).to_have_screenshot(f"{name}.png", {
                "max_diff_pixels": 200,
                "animations": "disabled",
            })
            print(f"✅ {name} 页面视觉测试通过")

        browser.close()

使用示例6:Trace Viewer 调试 | Trace Debugging

"""
示例6:Trace Viewer 调试
场景:测试失败时生成追踪文件,方便调试
"""

from playwright.sync_api import sync_playwright


def test_with_trace():
    """生成 Trace 文件用于调试"""
    with sync_playwright() as p:
        browser = p.chromium.launch()
        context = browser.new_context()

        # ===== 开始追踪 =====
        context.tracing.start(screenshots=True, snapshots=True, sources=True)

        page = context.new_page()
        page.goto("https://example.com")

        # 执行一些操作
        page.fill("#search", "playwright")
        page.press("#search", "Enter")

        # ===== 停止追踪并保存 =====
        context.tracing.stop(path="trace.zip")

        # 查看追踪文件:
        # npx playwright show-trace trace.zip
        # 会打开一个网页界面,展示:
        # - 每一步操作的截图
        # - DOM 快照
        # - 网络请求日志
        # - 控制台日志
        # - 操作时间线

        context.close()
        browser.close()


# ===== pytest 配置自动追踪 =====

# playwright.config.js / pytest.ini 配置:
# [pytest]
# addopts = --tracing on    # 测试失败时自动生成追踪

# 或者在 Python 中配置:
# pytest --tracing on --tracing retain-on-failure


# ===== 编程式查看追踪 =====

def test_programmatic_trace_analysis():
    """编程式分析追踪数据"""
    import zipfile
    import json

    # 读取 trace.zip 文件
    with zipfile.ZipFile("trace.zip", "r") as z:
        # 列出追踪文件中的所有文件
        print("追踪文件内容:")
        for name in z.namelist():
            print(f"  - {name}")

        # 读取追踪元数据
        if "trace.trace" in z.namelist():
            with z.open("trace.trace") as f:
                trace_data = json.loads(f.read())
                print(f"\n追踪时间: {trace_data.get('startTime', 'N/A')}")
                print(f"操作数量: {len(trace_data.get('actions', []))}")


# ===== 录制测试视频 =====

def test_record_video():
    """录制测试视频"""
    with sync_playwright() as p:
        browser = p.chromium.launch()

        # 配置视频录制
        context = browser.new_context(
            record_video_dir="videos/",
            record_video_size={"width": 1280, "height": 720},
        )

        page = context.new_page()
        page.goto("https://example.com")
        page.fill("#search", "playwright")
        page.press("#search", "Enter")
        page.wait_for_load_state("networkidle")

        # 关闭上下文时自动保存视频
        context.close()

        # 获取视频文件路径
        video_path = page.video.path()
        print(f"视频已保存到: {video_path}")

        browser.close()

🔧 高级配置 | Advanced Configuration

playwright.config.ts 完整配置

// playwright.config.ts —— 完整配置文件

const { defineConfig, devices } = require('@playwright/test');

module.exports = defineConfig({
    // ===== 测试目录 =====
    testDir: './tests',

    // ===== 全局超时 =====
    timeout: 30000,                    // 每个测试最长30秒
    expect: {
        timeout: 5000,                 // 断言超时5秒
    },

    // ===== 并行执行 =====
    fullyParallel: true,               // 完全并行执行
    workers: process.env.CI ? 4 : 2,   // CI环境4个worker,本地2个

    // ===== 失败重试 =====
    retries: process.env.CI ? 2 : 0,   // CI环境重试2次,本地不重试

    // ===== 报告器 =====
    reporter: [
        ['html', { open: 'never' }],   // HTML报告
        ['json', { outputFile: 'test-results.json' }],  // JSON报告
        ['list'],                        // 控制台列表
    ],

    // ===== 全局配置 =====
    use: {
        // 基础URL
        baseURL: 'http://localhost:3000',

        // 浏览器配置
        headless: true,                 // 无头模式
        viewport: { width: 1280, height: 720 },

        // 截图配置
        screenshot: 'only-on-failure',  // 仅失败时截图

        // 视频配置
        video: 'retain-on-failure',     // 仅失败时保留视频

        // 追踪配置
        trace: 'retain-on-failure',     // 仅失败时保留追踪

        // 网络配置
        offline: false,
        navigationTimeout: 15000,       // 导航超时15秒
        actionTimeout: 10000,           // 操作超时10秒
    },

    // ===== 多浏览器项目配置 =====
    projects: [
        {
            name: 'chromium',
            use: { ...devices['Desktop Chrome'] },
        },
        {
            name: 'firefox',
            use: { ...devices['Desktop Firefox'] },
        },
        {
            name: 'webkit',
            use: { ...devices['Desktop Safari'] },
        },
        {
            name: 'Mobile Chrome',
            use: { ...devices['Pixel 7'] },
        },
        {
            name: 'Mobile Safari',
            use: { ...devices['iPhone 14 Pro'] },
        },
        {
            name: 'iPad',
            use: { ...devices['iPad Pro 11'] },
        },
    ],

    // ===== 本地开发服务器 =====
    webServer: {
        command: 'npm run dev',
        url: 'http://localhost:3000',
        reuseExistingServer: !process.env.CI,  // CI环境不复用已有服务器
        timeout: 120000,
    },
});

pytest 配置

# pytest.ini —— Python 版本配置

# [pytest]
# addopts =
#     --headed                    # 有头模式(显示浏览器)
#     --browser chromium          # 使用 Chromium
#     --browser firefox           # 使用 Firefox
#     --browser webkit            # 使用 WebKit
#     --slowmo 100                # 每步操作间隔100ms
#     --tracing on                # 开启追踪
#     --screenshot only-on-failure  # 仅失败时截图
#     --video retain-on-failure   # 仅失败时保留视频
#     --output test-results       # 输出目录
#     -v                          # 详细输出
#     --tb=short                  # 简短的错误回溯

# ===== conftest.py —— 自定义 fixtures =====

import pytest
from playwright.sync_api import Page, BrowserContext


@pytest.fixture
def context_with_storage(browser):
    """带登录状态的浏览器上下文"""
    # 从文件加载存储状态(Cookie、localStorage等)
    context = browser.new_context(storage_state="auth/storage_state.json")
    yield context
    context.close()


@pytest.fixture
def logged_in_page(context_with_storage):
    """已登录的页面"""
    page = context_with_storage.new_page()
    page.goto("https://example.com/dashboard")
    yield page
    page.close()


@pytest.fixture
def mobile_context(browser):
    """移动端浏览器上下文"""
    from playwright.sync_api import sync_playwright
    with sync_playwright() as p:
        iphone = p.devices["iPhone 14 Pro"]
        context = browser.new_context(**iphone)
        yield context
        context.close()


# ===== 使用自定义 fixtures =====

def test_dashboard_with_login(logged_in_page):
    """使用已登录状态的测试"""
    # 无需登录,直接测试Dashboard
    assert "Dashboard" in logged_in_page.title()
    expect(logged_in_page.locator(".user-name")).to_have_text("测试用户")


def test_mobile_homepage(mobile_context):
    """使用移动端上下文的测试"""
    page = mobile_context.new_page()
    page.goto("https://example.com")
    expect(page.locator(".mobile-menu-toggle")).to_be_visible()

Agent 框架集成配置

# ===== Claude Code / OpenClaw 集成 =====

# SKILL.md 配置文件
SKILL_MD = """
# Playwright Browser Automation Skill

## Description
Use Playwright to automate browser interactions, take screenshots, run E2E tests,
and emulate mobile devices.

## Commands

### Take a screenshot of a webpage
```bash
npx playwright screenshot --viewport-size="1280,720" --full-page <URL> <output.png>

Run a Playwright test

npx playwright test <test-file> --headed

Generate test code by recording

npx playwright codegen <URL>

Check page accessibility

npx playwright test --project=chromium accessibility.spec.ts

Examples

  • "Take a screenshot of example.com"
  • "Run the login E2E test"
  • "Test the mobile responsive layout"
  • "Check if the homepage has any accessibility issues" """

===== LangChain 集成 =====

from langchain.agents import AgentExecutor, create_openai_tools_agent from langchain.tools import tool from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate

@tool def take_screenshot(url: str, output_path: str = "screenshot.png") -> str: """ 对网页进行截图。 参数: url: 要截图的网页URL output_path: 截图保存路径 返回:截图文件路径 """ from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page(viewport={"width": 1280, "height": 720})
    page.goto(url, wait_until="networkidle", timeout=30000)
    page.screenshot(path=output_path, full_page=True)
    browser.close()

return f"截图已保存到 {output_path}"

@tool def run_e2e_test(test_file: str) -> str: """ 运行 Playwright 端到端测试。 参数: test_file: 测试文件路径 返回:测试结果 """ import subprocess result = subprocess.run( ["npx", "playwright", "test", test_file, "--reporter=list"], capture_output=True, text=True, timeout=120, ) return result.stdout if result.returncode == 0 else f"测试失败:\n{result.stderr}"

@tool def check_mobile_responsive(url: str) -> str: """ 检查网页的移动端响应式效果。 参数: url: 要检查的网页URL 返回:各设备的截图路径列表 """ from playwright.sync_api import sync_playwright

results = []
with sync_playwright() as p:
    browser = p.chromium.launch()
    devices = [
        ("iPhone 14 Pro", p.devices["iPhone 14 Pro"]),
        ("iPad Pro 11", p.devices["iPad Pro 11"]),
        ("Desktop", {"viewport": {"width": 1920, "height": 1080}}),
    ]

    for name, config in devices:
        context = browser.new_context(**config)
        page = context.new_page()
        page.goto(url, wait_until="networkidle", timeout=30000)
        safe_name = name.replace(" ", "_").lower()
        filepath = f"responsive_{safe_name}.png"
        page.screenshot(path=filepath, full_page=True)
        results.append(f"{name}: {filepath}")
        context.close()

    browser.close()

return "\n".join(results)

创建 Agent

llm = ChatOpenAI(model="gpt-4o", temperature=0)

prompt = ChatPromptTemplate.from_messages([ ("system", """你是一个浏览器自动化测试助手。你可以使用以下工具: - take_screenshot: 对网页截图 - run_e2e_test: 运行端到端测试 - check_mobile_responsive: 检查移动端响应式效果

请根据用户需求选择合适的工具。"""),
("human", "{input}"),
("placeholder", "{agent_scratchpad}"),

])

tools = [take_screenshot, run_e2e_test, check_mobile_responsive] agent = create_openai_tools_agent(llm, tools, prompt) agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)

使用示例

result = agent_executor.invoke({ "input": "帮我检查 example.com 在手机端和桌面端的显示效果" }) print(result['output'])


## 📊 效果对比 | Before & After

### 对比1:Selenium vs Playwright

| 维度 | Selenium | Playwright |
|------|----------|------------|
| 安装配置 | 需下载Driver,版本匹配 | 一条命令搞定 ✔️ |
| 自动等待 | 手动sleep或显式等待 | 内置智能等待 ✔️ |
| 跨浏览器 | 需配置不同Driver | 三引擎统一API ✔️ |
| 测试隔离 | 手动清理Cookie | 浏览器上下文隔离 ✔️ |
| 执行速度 | 慢(WebDriver协议开销) | 快(直接CDP通信) ✔️ |
| 网络拦截 | 需Proxy | 内置Route API ✔️ |
| 移动模拟 | 需Appium | 内置设备模拟 ✔️ |
| 调试能力 | 有限 | Trace Viewer ✔️ |
| 代码生成 | Selenium IDE | Playwright Codegen ✔️ |
| API设计 | 命令式 | 自动等待+声明式 ✔️ |

### 对比2:Cypress vs Playwright

| 维度 | Cypress | Playwright |
|------|---------|------------|
| 浏览器支持 | 仅Chromium系 | Chromium/Firefox/WebKit ✔️ |
| 测试语言 | 仅JavaScript | Python/JS/Java/.NET ✔️ |
| 并行执行 | 需付费Dashboard | 内置Worker并行 ✔️ |
| 多标签页 | 不支持 | 原生支持 ✔️ |
| 多域名 | 不支持 | 原生支持 ✔️ |
| 移动模拟 | 不支持 | 内置设备模拟 ✔️ |
| 执行速度 | 中 | 快 ✔️ |
| 社区生态 | 成熟 | 快速增长 ✔️ |
| 学习曲线 | 低 | 低 ✔️ |
| 开源免费 | 基础版免费 | 完全免费 ✔️ |

### 对比3:开发效率

| 任务 | Selenium | Cypress | Playwright |
|------|----------|---------|------------|
| 环境搭建 | 2小时 | 30分钟 | 10分钟 ✔️ |
| 简单登录测试 | 30分钟 | 15分钟 | 10分钟 ✔️ |
| 跨浏览器测试 | 1天 | 不支持 | 30分钟 ✔️ |
| 移动端测试 | 需Appium | 不支持 | 15分钟 ✔️ |
| 网络Mock | 需Proxy | 内置 | 内置 ✔️ |
| 视觉回归测试 | 需第三方 | 需第三方 | 内置 ✔️ |
| 调试失败测试 | 困难 | Time Travel | Trace Viewer ✔️ |
| CI集成 | 复杂 | 简单 | 简单 ✔️ |

## 🎨 定制项 | Customization Options

| 项目 | 修改方法 | 效果预览 |
|------|----------|----------|
| 浏览器引擎 | `browser: chromium/firefox/webkit` | 切换三大浏览器引擎 |
| 视口尺寸 | `viewport: {width: 1280, height: 720}` | 自定义窗口大小 |
| 设备像素比 | `deviceScaleFactor: 2` | Retina 屏幕模拟 |
| 深色模式 | `colorScheme: "dark"` | 模拟深色模式 |
| 地理位置 | `geolocation: {latitude, longitude}` | 模拟GPS定位 |
| 语言区域 | `locale: "zh-CN"` | 模拟中文环境 |
| 时区 | `timezoneId: "Asia/Shanghai"` | 模拟上海时区 |
| 权限 | `permissions: ["geolocation"]` | 授予/拒绝权限 |
| 用户代理 | `userAgent: "custom-ua"` | 自定义UA字符串 |
| HTTP认证 | `httpCredentials: {username, password}` | Basic Auth |
| 离线模式 | `offline: true` | 模拟断网环境 |
| 代理 | `proxy: {server, username, password}` | HTTP代理 |
| 存储状态 | `storageState: "state.json"` | 复用登录状态 |
| 录制视频 | `recordVideo: {dir, size}` | 录制测试视频 |
| 追踪 | `tracing: {screenshots, snapshots}` | 开启操作追踪 |
| 截图 | `screenshot: "only-on-failure"` | 失败时截图 |
| 超时 | `timeout: 30000` | 自定义超时时间 |
| 慢动作 | `slowMo: 500` | 每步间隔500ms |
| 无头模式 | `headless: false` | 显示浏览器窗口 |

## 🏗️ 架构对比 | Architecture Comparison

### vs Selenium

| 维度 | Selenium | Playwright |
|------|----------|------------|
| 架构 | WebDriver协议 | 自定义协议(CDP等) |
| 通信方式 | HTTP REST API | WebSocket长连接 ✔️ |
| 等待机制 | 手动/显式等待 | 自动智能等待 ✔️ |
| 浏览器支持 | 需各浏览器Driver | 内置三引擎 ✔️ |
| 执行速度 | 慢(HTTP开销) | 快(WebSocket) ✔️ |
| 测试隔离 | 手动管理 | 浏览器上下文 ✔️ |
| 网络控制 | 需Proxy | 内置Route API ✔️ |
| 调试能力 | 有限 | Trace Viewer ✔️ |
| 移动端 | 需Appium | 内置设备模拟 ✔️ |
| 社区 | 非常成熟 | 快速增长 |
| 类比 | 手动挡汽车 | 自动驾驶 🚗 |

### vs Cypress

| 维度 | Cypress | Playwright |
|------|---------|------------|
| 架构 | 注入浏览器内运行 | 进程外控制浏览器 |
| 浏览器支持 | 仅Chromium | 三引擎 ✔️ |
| 跨域支持 | 有限制 | 无限制 ✔️ |
| 多标签页 | 不支持 | 支持 ✔️ |
| 多浏览器实例 | 不支持 | 支持 ✔️ |
| 语言支持 | 仅JavaScript | Python/JS/Java/.NET ✔️ |
| 执行速度 | 中 | 快 ✔️ |
| 调试体验 | Time Travel | Trace Viewer ✔️ |
| 移动端 | 不支持 | 内置模拟 ✔️ |
| 开源免费 | 基础版免费 | 完全免费 ✔️ |
| 类比 | 单车道公路 | 多车道高速 🛣️ |

### vs Puppeteer

| 维度 | Puppeteer | Playwright |
|------|-----------|------------|
| 开发团队 | Google | Microsoft |
| 浏览器支持 | 仅Chromium | 三引擎 ✔️ |
| 自动等待 | 有限 | 完整 ✔️ |
| 测试框架 | 无内置 | 内置test runner ✔️ |
| 选择器 | CSS/XPath | CSS/XPath/text/role ✔️ |
| 网络拦截 | 有限 | 完整Route API ✔️ |
| 移动端 | 有限 | 完整设备模拟 ✔️ |
| 追踪调试 | 有限 | Trace Viewer ✔️ |
| 社区 | 大 | 快速增长 |
| 类比 | Chrome专用工具 | 全能浏览器工具箱 🧰 |

## 🐛 常见问题 | Troubleshooting

### 1. 浏览器安装失败?

```bash
# 问题:playwright install 下载浏览器失败

# 方案1:使用国内镜像
PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright playwright install

# 方案2:只安装需要的浏览器
playwright install chromium  # 只安装 Chromium

# 方案3:手动下载浏览器
# 设置环境变量指定浏览器路径
PLAYWRIGHT_BROWSERS_PATH=/path/to/browsers playwright install

# 方案4:Docker 环境中安装
docker run -it mcr.microsoft.com/playwright/python bash
playwright install --with-deps chromium  # 同时安装系统依赖

2. 元素找不到?

# 问题:page.click("#submit") 报错 Element not found

# 方案1:使用更灵活的选择器
page.click("text=提交")           # 文本选择器
page.click("[data-testid=submit]") # data-testid 选择器
page.click("button:has-text('提交')")  # 组合选择器
page.get_by_role("button", name="提交")  # 角色选择器(推荐)
page.get_by_text("提交")           # 文本匹配

# 方案2:等待元素出现
page.wait_for_selector("#submit", state="visible")
page.click("#submit")

# 方案3:调试选择器
# 使用 Playwright Inspector 调试
# PWDEBUG=1 pytest test_file.py

# 方案4:使用 locator 链式调用
page.locator("form").locator("button").first.click()

3. 超时问题?

# 问题:测试经常超时

# 方案1:增加全局超时
page.set_default_timeout(30000)  # 30秒
page.set_default_navigation_timeout(60000)  # 导航60秒

# 方案2:单次操作自定义超时
page.goto("https://slow-site.com", timeout=60000)
page.click("#submit", timeout=15000)

# 方案3:等待特定条件而非固定时间
# 不要用 time.sleep
# 用 wait_for_* 系列方法
page.wait_for_load_state("networkidle")
page.wait_for_selector(".loaded")
page.wait_for_url("**/dashboard")

# 方案4:CI 环境增加超时
# pytest --timeout=120  # 每个测试最长120秒

4. CI 环境运行失败?

# GitHub Actions 配置示例

name: Playwright Tests
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    container:
      image: mcr.microsoft.com/playwright/python:v1.48.0-noble

    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Install Playwright browsers
        run: playwright install --with-deps

      - name: Run tests
        run: pytest tests/ --headed

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: |
            test-results/
            trace.zip

5. 并行测试冲突?

# 问题:并行测试时数据互相干扰

# 方案1:使用浏览器上下文隔离
# 每个测试用独立的浏览器上下文
def test_isolated(browser):
    context = browser.new_context()  # 独立上下文
    page = context.new_page()
    page.goto("https://example.com")
    # ... 测试 ...
    context.close()  # 关闭时自动清理

# 方案2:使用存储状态复用登录
# 先保存登录状态
def test_save_auth_state(browser):
    context = browser.new_context()
    page = context.new_page()
    page.goto("https://example.com/login")
    page.fill("#username", "testuser")
    page.fill("#password", "password")
    page.click("#submit")
    context.storage_state(path="auth.json")  # 保存登录状态
    context.close()

# 后续测试复用登录状态
def test_with_saved_auth(browser):
    context = browser.new_context(storage_state="auth.json")
    page = context.new_page()
    page.goto("https://example.com/dashboard")
    # 已经是登录状态,无需重新登录
    context.close()

# 方案3:使用 Worker 级别的 fixture
# conftest.py
import pytest

@pytest.fixture(scope="worker")
def worker_context(browser):
    """每个 Worker 独立的浏览器上下文"""
    context = browser.new_context()
    yield context
    context.close()

6. 处理弹窗和对话框?

# 问题:如何处理 alert/confirm/prompt 弹窗

# 方案1:监听 dialog 事件
page.on("dialog", lambda dialog: dialog.accept())

# 方案2:带输入的弹窗
def handle_dialog(dialog):
    print(f"弹窗类型: {dialog.type}")
    print(f"弹窗消息: {dialog.message}")
    dialog.accept("输入内容")  # 接受并输入

page.on("dialog", handle_dialog)
page.click("#trigger-prompt")

# 方案3:取消弹窗
page.on("dialog", lambda dialog: dialog.dismiss())

# 方案4:文件下载
with page.expect_download() as download_info:
    page.click("#download-button")
download = download_info.value
print(f"下载文件: {download.suggested_filename}")
download.save_as("downloaded_file.pdf")

📚 扩展学习资源 | Extended Resources

官方文档

教程与指南

社区资源

Agent 框架集成

Conclusion | 结语

  • That's all for today~ - | 今天就写到这里啦~

  • Guys, ( ̄ω ̄( ̄ω ̄〃 ( ̄ω ̄〃)ゝ See you tomorrow~~ | 小伙伴们,( ̄ω ̄( ̄ω ̄〃 ( ̄ω ̄〃)ゝ我们明天再见啦~~

  • Everyone, be happy every day! 大家要天天开心哦

  • Welcome everyone to point out any mistakes in the article~ | 欢迎大家指出文章需要改正之处~

  • Learning has no end; win-win cooperation | 学无止境,合作共赢

  • Welcome all the passers-by, boys and girls, to offer better suggestions! ~~~ | 欢迎路过的小哥哥小姐姐们提出更好的意见哇~~

image