Skip to content

第 18 章 · 异步 API 与 FastAPI 联测

本章目标:掌握 Playwright 的异步 API(async_api),学会在 pytest-asyncio 下编写异步 E2E 测试,并把浏览器测试与 FastAPI 应用组合成完整的端到端联测方案。

18.1 同步与异步:两套平行的 API

Playwright Python 是少数同时提供两套完整平行 API 的自动化库:

  • 同步 APIfrom playwright.sync_api import sync_playwright,本课程前 17 章都在用它;
  • 异步 APIfrom playwright.async_api import async_playwright,所有方法返回协程,需要 await

两者底层驱动完全相同(都通过管道与浏览器进程通信),功能一一对应,选择标准只有一个——你的应用和测试栈是同步还是异步的

python
# 同步版本(前面章节一直在用)
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("https://example.com")
    print(page.title())
    browser.close()
python
# 异步版本:同样的流程
import asyncio
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()
        await page.goto("https://example.com")
        print(await page.title())
        await browser.close()

asyncio.run(main())

命名规律

异步 API 与同步 API 的类名和方法名几乎完全一致,差异只有三点:入口从 sync_playwright() 换成 async_playwright();每个会产生 I/O 的方法前加 await;断言从 playwright.sync_api.expect 换成 playwright.async_api.expect(异步版 expect 接受协程并自动等待)。

18.2 async def 测试与 pytest-asyncio

pytest 本身不支持协程测试函数,需要 pytest-asyncio 插件。而 pytest-playwright 插件默认提供的 page fixture 是同步的——异步场景下要用 Playwright 自带的异步 fixture 体系:

bash
pip install pytest-playwright pytest-asyncio
python
# test_async_demo.py
import pytest
from playwright.async_api import Page, expect

@pytest.mark.asyncio
async def test_title_is_async(page: Page):
    # pytest-playwright 同时提供异步的 page fixture,
    # 在 asyncio 标记下注入的就是 async 版 Page
    await page.goto("https://playwright.dev/")
    await expect(page).to_have_title("Fast and reliable end-to-end testing")

关键点:

  • 异步 fixture 下,page.goto()locator.click()都必须 await
  • expect(page) 来自 playwright.async_api,它的 to_have_title 等断言同样是自动重试的 web-first 断言;
  • 如果忘记 await,得到的往往不是一个报错,而是一个未消费的协程对象——操作根本没执行,后续断言以"元素不存在"失败。这是异步 E2E 最常见的坑。

18.3 异步 API 的差异清单

除了"处处 await",还有几个容易踩坑的差异:

场景同步写法异步写法
等待导航page.click("...")await page.click("...")
取文本page.locator("h1").inner_text()await page.locator("h1").inner_text()
事件监听回调普通 def handler(x)def handler(x)async def 均可
expect_eventwith page.expect_event("popup") as pi:async with page.expect_event("popup") as pi:
多任务并发不适用await asyncio.gather(task1(), task2())
python
import pytest
from playwright.async_api import Page, expect

@pytest.mark.asyncio
async def test_popup_with_expect_event(page: Page):
    await page.goto("https://example.com")  # 页面里有 target=_blank 链接

    # 异步上下文管理器写法:进入时开始监听,退出时取结果
    async with page.expect_event("popup") as popup_info:
        await page.get_by_role("link", name="open").click()
    popup = await popup_info.value
    await expect(popup.get_by_role("heading")).to_be_visible()

18.4 组合 httpx.AsyncClient 联测 FastAPI

异步栈的最大价值在于:同一个事件循环里既跑浏览器又调 API。典型场景是"E2E 操作页面 + 直接调后端接口准备/校验数据":

python
# conftest.py —— 启动真实 uvicorn 服务供 E2E 使用
import pytest
import uvicorn
from multiprocessing import Process

@pytest.fixture(scope="session")
def fastapi_server():
    """在子进程中启动真实的 FastAPI 服务"""
    from myapp.main import app  # 你的 FastAPI 应用

    proc = Process(target=uvicorn.run, args=(app,),
                   kwargs={"host": "127.0.0.1", "port": 8000, "log_level": "warning"},
                   daemon=True)
    proc.start()
    import httpx
    # 轮询等服务就绪
    for _ in range(50):
        try:
            if httpx.get("http://127.0.0.1:8000/healthz").status_code == 200:
                break
        except Exception:
            pass
    yield "http://127.0.0.1:8000"
    proc.terminate()
python
# test_fastapi_e2e.py —— 浏览器 + API 双通道验证
import pytest
from playwright.async_api import Page, expect

BASE = "http://127.0.0.1:8000"

@pytest.mark.asyncio
async def test_create_item_via_ui_verify_via_api(
    page: Page, fastapi_server
):
    from httpx import AsyncClient

    # 1) 通过 UI 创建一条数据
    await page.goto(f"{BASE}/items/new")
    await page.get_by_label("名称").fill("机械键盘")
    await page.get_by_role("button", name="提交").click()
    await expect(page.get_by_role("alert")).to_contain_text("创建成功")

    # 2) 绕过 UI,直接用 API 验证数据真的落库了
    async with AsyncClient(base_url=BASE) as client:
        resp = await client.get("/api/items", params={"q": "机械键盘"})
        assert resp.status_code == 200
        assert any(i["name"] == "机械键盘" for i in resp.json()["items"])

这种"UI 写入 + API 校验"的组合,比纯 UI 断言更快也更稳定——数据正确性交给 API 断言,UI 只负责验证交互流程。

事件循环冲突警告

不要在同一进程里先 uvicorn.run()asyncio.run() 测试:uvicorn 会尝试创建自己的事件循环并与 pytest-asyncio 冲突。上面的示例用子进程启动服务就是为了规避这一点,这也是最稳妥的做法。

18.5 本章小结

  • Playwright 提供平行的同步/异步两套 API,方法名一致、异步版处处 await
  • 异步测试用 @pytest.mark.asyncio + playwright.async_apiPage/expect
  • 忘记 await 的典型症状是"操作没执行、断言莫名失败",而不是显式报错;
  • 用子进程启动 uvicorn 提供 E2E 后端,配合 httpx.AsyncClient 实现"UI 写入 + API 校验"的双通道联测。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. 在异步测试中调用 page.click() 时忘记写 await,最可能的表现是?

2. 异步版本的 web-first 断言应该从哪里导入 expect?

3. E2E 测试需要真实后端时,为什么推荐用子进程启动 uvicorn?

4. 关于"UI 写入 + API 校验"的联测模式,正确的理解是?

🛠️ 动手实践

  1. 把第 6 章任意一个同步测试改写成异步版本,运行 pytest --asyncio-mode=auto 对比两者输出。
  2. 为你的 FastAPI 项目(或练习项目)实现 fastapi_server fixture,并写一个"UI 创建 + API 查询校验"的测试。
  3. 故意删掉一个 await 运行测试,观察 -W error::RuntimeWarning 参数下 pytest 的表现,体会这个陷阱的检测方式。