第 18 章 · 异步 API 与 FastAPI 联测
本章目标:掌握 Playwright 的异步 API(
async_api),学会在 pytest-asyncio 下编写异步 E2E 测试,并把浏览器测试与 FastAPI 应用组合成完整的端到端联测方案。
18.1 同步与异步:两套平行的 API
Playwright Python 是少数同时提供两套完整平行 API 的自动化库:
- 同步 API:
from playwright.sync_api import sync_playwright,本课程前 17 章都在用它; - 异步 API:
from playwright.async_api import async_playwright,所有方法返回协程,需要await。
两者底层驱动完全相同(都通过管道与浏览器进程通信),功能一一对应,选择标准只有一个——你的应用和测试栈是同步还是异步的:
# 同步版本(前面章节一直在用)
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()# 异步版本:同样的流程
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 体系:
pip install pytest-playwright pytest-asyncio# 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_event | with page.expect_event("popup") as pi: | async with page.expect_event("popup") as pi: |
| 多任务并发 | 不适用 | await asyncio.gather(task1(), task2()) |
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 操作页面 + 直接调后端接口准备/校验数据":
# 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()# 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_api的Page/expect; - 忘记
await的典型症状是"操作没执行、断言莫名失败",而不是显式报错; - 用子进程启动 uvicorn 提供 E2E 后端,配合
httpx.AsyncClient实现"UI 写入 + API 校验"的双通道联测。
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. 在异步测试中调用 page.click() 时忘记写 await,最可能的表现是?
2. 异步版本的 web-first 断言应该从哪里导入 expect?
3. E2E 测试需要真实后端时,为什么推荐用子进程启动 uvicorn?
4. 关于"UI 写入 + API 校验"的联测模式,正确的理解是?
🛠️ 动手实践
- 把第 6 章任意一个同步测试改写成异步版本,运行
pytest --asyncio-mode=auto对比两者输出。 - 为你的 FastAPI 项目(或练习项目)实现
fastapi_serverfixture,并写一个"UI 创建 + API 查询校验"的测试。 - 故意删掉一个
await运行测试,观察-W error::RuntimeWarning参数下 pytest 的表现,体会这个陷阱的检测方式。