用 Playwright 做自动化测试:如何验证网络请求并做断言

上一篇文章里,我验证的是:

同一个 H5 SDK,在不同设备上首次游客登录时,是否会生成不同的游客账号?

当时的核心是模拟不同浏览器环境,然后点击初始化、游客登录,最后从操作日志里取出 userName

继续往下写自动化时,我发现只看页面结果还不够。

因为 SDK 很多功能,真正重要的行为并不只发生在页面上,而是发生在接口请求里。

比如游客登录,页面上看起来只是点了一个按钮,但背后实际会发出多个请求。

这次我想验证的事情就变成了:

点击按钮之后,SDK 是否真的发出了正确的网络请求?

请求参数是否正确?

接口响应是否成功?

也就是说,这次不只验证页面有没有变化,还要验证网络层是否符合预期。


这次到底要断言什么

对于一次接口请求来说,我实际想验证 4 件事:

text 复制代码
1. 有没有发出目标请求
2. 请求参数是否正确
3. HTTP 状态码是否正确
4. 业务响应是否正确

放到游客登录流程里,就是:

点击初始化后,需要验证:

text 复制代码
是否请求了 client/init
响应是否成功

点击游客登录后,需要验证:

text 复制代码
是否请求了 oauth2/login
响应是否成功
是否返回了 access_token

是否请求了 playergameuser/getUserById
响应是否成功

是否请求了 adjusteventrecord/save
请求参数 eventName 是否等于 sdk_登录成功
响应是否成功

这次要校验的接口如下。

点击初始化时:

text 复制代码
https://sdk-test.uggamer.com/test-api/client/init

点击游客登录时:

text 复制代码
https://sdk-test.uggamer.com/test-api/oauth2/login

https://sdk-test.uggamer.com/test-api/oauth2/playergameuser/getUserById

https://sdk-test.uggamer.com/test-api/client/adjusteventrecord/save

其中第三个接口是埋点上报接口,登录成功后还要校验它的请求参数:

json 复制代码
{
  "eventName": "sdk_登录成功"
}

第一次:只看页面结果

最开始,我还是只写了页面断言:

python 复制代码
page.locator("#initSDK").click()
expect(page.locator("#sdkStatus")).to_have_text("SDK 初始化成功")

page.locator("#testGuestLogin").click()

这种方式可以证明页面流程大致走通了。

但问题是:

text 复制代码
页面显示成功,不代表网络请求一定正确。

比如:

  1. 登录接口请求成功了,但没有返回 access_token
  2. 用户信息接口失败了,但页面没有暴露出来
  3. 埋点接口根本没有请求
  4. 埋点接口请求了,但 eventName 传错了

所以只看页面不够。

页面断言只能说明:

text 复制代码
用户看到的结果大致是对的

但它不能完整说明:

text 复制代码
SDK 背后的行为也是对的

第二次:手工打开 Network 看请求

既然要看接口,那最直接的办法就是打开浏览器的 Network 面板。

点击初始化,看有没有:

text 复制代码
client/init

点击游客登录,看有没有:

text 复制代码
oauth2/login
playergameuser/getUserById
adjusteventrecord/save

手工验证当然可以。

但它解决不了自动化的问题。

因为每次回归时,还是要靠人去 Network 里翻请求、看参数、看响应。

我真正需要的是:

text 复制代码
代码负责点击按钮
代码负责捕获请求
代码负责校验参数和响应

也就是说,要把人手工在 Network 面板里的检查动作,变成自动化断言。


第三次:让代码接管 Network 面板里的检查

Playwright 可以监听页面上的请求和响应。

核心就是两个事件:

python 复制代码
page.on("request", handle_request)
page.on("response", handle_response)

request 可以拿到:

text 复制代码
请求地址
请求方法
请求参数

response 可以拿到:

text 复制代码
HTTP 状态码
响应内容

所以这次的实现思路就变成了:

text 复制代码
点击按钮前,先开始监听请求
点击按钮后,等待目标请求出现
拿到请求和响应后,再做断言

注意这里不是"点击后立刻查找请求"。

因为页面点击只是触发动作,SDK 内部真正发起请求可能还需要一点时间。

如果点击后马上去断言,很容易出现:

text 复制代码
请求还没发出
代码已经开始判断
最后被误判为没有请求

所以更合理的流程应该是:

text 复制代码
点击按钮
    -> 等待目标请求出现
    -> 等待响应返回
    -> 再断言请求和响应

先封装一个网络记录器

我先写一个简单的 NetworkRecorder

它负责两件事:

text 复制代码
记录页面产生过的请求和响应
等待指定接口真正完成后再返回
python 复制代码
import time
from typing import Any, Dict, List, Optional


class NetworkRecorder:
    def __init__(self, page):
        self.page = page
        self.records: List[Dict[str, Any]] = []

    def start(self):
        self.page.on("request", self._on_request)
        self.page.on("response", self._on_response)

    def _on_request(self, request):
        post_data = None

        try:
            post_data = request.post_data_json
        except Exception:
            try:
                post_data = request.post_data
            except Exception:
                post_data = None

        self.records.append({
            "url": request.url,
            "method": request.method,
            "post_data": post_data,
            "status": None,
            "response_json": None,
            "response_text": None,
        })

    def _on_response(self, response):
        matched_record = None

        for record in reversed(self.records):
            if record["url"] == response.url and record["status"] is None:
                matched_record = record
                break

        if matched_record is None:
            return

        matched_record["status"] = response.status

        try:
            matched_record["response_json"] = response.json()
        except Exception:
            try:
                matched_record["response_text"] = response.text()
            except Exception:
                matched_record["response_text"] = None

    def find_by_url_contains(self, keyword: str) -> Optional[Dict[str, Any]]:
        for record in reversed(self.records):
            if keyword in record["url"]:
                return record
        return None

    def wait_for_url_contains(self, keyword: str, timeout: int = 10000) -> Dict[str, Any]:
        deadline = time.time() + timeout / 1000

        while time.time() < deadline:
            record = self.find_by_url_contains(keyword)

            if record is not None and record["status"] is not None:
                return record

            self.page.wait_for_timeout(100)

        assert False, (
            f"等待请求超时:{keyword}\n"
            f"当前已捕获请求:\n{self.dump_urls()}"
        )

    def dump_urls(self) -> str:
        return "\n".join([
            f'{item["method"]} {item["url"]} status={item["status"]}'
            for item in self.records
        ])

这里最关键的是:

python 复制代码
wait_for_url_contains()

它不是简单地"找一下有没有请求",而是:

text 复制代码
在超时时间内持续等待
直到目标请求出现
并且响应也已经返回

这样后面再去断言状态码和响应体时,拿到的数据才是完整的。


再封装两个断言方法

记录到请求之后,还要继续判断它是否正确。

我这里先封装两个最常用的断言。

第一个,用来断言响应是否成功:

python 复制代码
def assert_response_success(record):
    assert record["status"] == 200, (
        f"HTTP 状态码不是 200:{record['status']}\n"
        f"请求地址:{record['url']}\n"
        f"响应内容:{record['response_json'] or record['response_text']}"
    )

    response_json = record["response_json"]

    assert response_json is not None, (
        f"响应不是 JSON:{record['url']}\n"
        f"响应内容:{record['response_text']}"
    )

    assert response_json.get("code") == 200, (
        f"业务 code 不是 200:{response_json}\n"
        f"请求地址:{record['url']}"
    )

第二个,用来断言请求参数是否正确:

python 复制代码
import json


def assert_request_field(record, field_name, expected_value):
    post_data = record["post_data"]

    assert isinstance(post_data, dict), (
        f"请求体不是 JSON 对象:{record['url']}\n"
        f"请求体:{post_data}"
    )

    actual_value = post_data.get(field_name)

    assert actual_value == expected_value, (
        f"请求参数 {field_name} 不符合预期\n"
        f"预期:{expected_value}\n"
        f"实际:{actual_value}\n"
        f"请求体:{json.dumps(post_data, ensure_ascii=False, indent=2)}"
    )

这样,埋点接口的校验就很清楚了:

python 复制代码
event_record = recorder.wait_for_url_contains(
    "/test-api/client/adjusteventrecord/save"
)

assert_response_success(event_record)

assert_request_field(
    event_record,
    "eventName",
    "sdk_登录成功"
)

到这里,网络断言的 4 个层次就都具备了:

text 复制代码
wait_for_url_contains()      -> 校验请求是否发出
assert_request_field()       -> 校验请求参数
assert_response_success()    -> 校验 HTTP 状态码和业务 code
额外字段断言               -> 校验 access_token 等业务数据

最终代码

下面是最终保留下来的核心代码。

这版代码继续沿用上一篇文章的方式:

text 复制代码
使用 Playwright 启动浏览器
打开 SDK 页面
点击初始化
点击游客登录
捕获并断言网络请求
python 复制代码
import json
import time
from typing import Any, Dict, List, Optional

from playwright.sync_api import sync_playwright, expect


SDK_URL = "https://sdk-test.uggamer.com/h5/test-sdk.html"

INIT_API = "/test-api/client/init"
LOGIN_API = "/test-api/oauth2/login"
GET_USER_API = "/test-api/oauth2/playergameuser/getUserById"
EVENT_API = "/test-api/client/adjusteventrecord/save"


class NetworkRecorder:
    def __init__(self, page):
        self.page = page
        self.records: List[Dict[str, Any]] = []

    def start(self):
        self.page.on("request", self._on_request)
        self.page.on("response", self._on_response)

    def _on_request(self, request):
        post_data = None

        try:
            post_data = request.post_data_json
        except Exception:
            try:
                post_data = request.post_data
            except Exception:
                post_data = None

        self.records.append({
            "url": request.url,
            "method": request.method,
            "post_data": post_data,
            "status": None,
            "response_json": None,
            "response_text": None,
        })

    def _on_response(self, response):
        matched_record = None

        for record in reversed(self.records):
            if record["url"] == response.url and record["status"] is None:
                matched_record = record
                break

        if matched_record is None:
            return

        matched_record["status"] = response.status

        try:
            matched_record["response_json"] = response.json()
        except Exception:
            try:
                matched_record["response_text"] = response.text()
            except Exception:
                matched_record["response_text"] = None

    def find_by_url_contains(self, keyword: str) -> Optional[Dict[str, Any]]:
        for record in reversed(self.records):
            if keyword in record["url"]:
                return record
        return None

    def wait_for_url_contains(self, keyword: str, timeout: int = 10000) -> Dict[str, Any]:
        deadline = time.time() + timeout / 1000

        while time.time() < deadline:
            record = self.find_by_url_contains(keyword)

            if record is not None and record["status"] is not None:
                return record

            self.page.wait_for_timeout(100)

        assert False, (
            f"等待请求超时:{keyword}\n"
            f"当前已捕获请求:\n{self.dump_urls()}"
        )

    def dump_urls(self) -> str:
        return "\n".join([
            f'{item["method"]} {item["url"]} status={item["status"]}'
            for item in self.records
        ])


def assert_response_success(record):
    assert record["status"] == 200, (
        f"HTTP 状态码不是 200:{record['status']}\n"
        f"请求地址:{record['url']}\n"
        f"响应内容:{record['response_json'] or record['response_text']}"
    )

    response_json = record["response_json"]

    assert response_json is not None, (
        f"响应不是 JSON:{record['url']}\n"
        f"响应内容:{record['response_text']}"
    )

    assert response_json.get("code") == 200, (
        f"业务 code 不是 200:{response_json}\n"
        f"请求地址:{record['url']}"
    )


def assert_request_field(record, field_name, expected_value):
    post_data = record["post_data"]

    assert isinstance(post_data, dict), (
        f"请求体不是 JSON 对象:{record['url']}\n"
        f"请求体:{post_data}"
    )

    actual_value = post_data.get(field_name)

    assert actual_value == expected_value, (
        f"请求参数 {field_name} 不符合预期\n"
        f"预期:{expected_value}\n"
        f"实际:{actual_value}\n"
        f"请求体:{json.dumps(post_data, ensure_ascii=False, indent=2)}"
    )


def test_guest_login_network_request():
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=False)

        context = browser.new_context()
        page = context.new_page()

        recorder = NetworkRecorder(page)
        recorder.start()

        page.goto(SDK_URL, wait_until="domcontentloaded")

        channel_id_input = page.locator("#channelIdInput")
        expect(channel_id_input).not_to_have_value("", timeout=10000)

        page.locator("#initSDK").click()
        expect(page.locator("#sdkStatus")).to_have_text("SDK 初始化成功", timeout=10000)

        init_record = recorder.wait_for_url_contains(INIT_API, timeout=10000)
        assert_response_success(init_record)

        page.locator("#testGuestLogin").click()

        login_record = recorder.wait_for_url_contains(LOGIN_API, timeout=10000)
        assert_response_success(login_record)

        login_response = login_record["response_json"]
        access_token = login_response.get("data", {}).get("access_token")

        assert access_token, (
            f"游客登录接口没有返回 access_token:"
            f"{json.dumps(login_response, ensure_ascii=False, indent=2)}"
        )

        user_record = recorder.wait_for_url_contains(GET_USER_API, timeout=10000)
        assert_response_success(user_record)

        event_record = recorder.wait_for_url_contains(EVENT_API, timeout=10000)
        assert_response_success(event_record)

        assert_request_field(
            event_record,
            "eventName",
            "sdk_登录成功"
        )

        print("本次捕获到的请求:")
        print(recorder.dump_urls())

        context.close()
        browser.close()

页面断言和网络断言的关系

代码里我没有删掉页面断言。

比如初始化后仍然保留了:

python 复制代码
expect(page.locator("#sdkStatus")).to_have_text("SDK 初始化成功")

因为页面断言和网络断言解决的不是同一个问题。

text 复制代码
页面断言:验证用户看到的结果
网络断言:验证 SDK 背后的真实行为

对于自动化测试来说,两者最好都保留。

因为我既希望确认:

text 复制代码
页面流程走通了

也希望确认:

text 复制代码
请求真的发了
参数真的对了
响应也真的成功了

这次代码真正解决的问题

上一篇文章里,我更关注的是:

text 复制代码
怎么模拟不同浏览器环境,让不同设备生成不同游客。

这一篇继续往下走,关注的是:

text 复制代码
怎么把 Network 面板里的检查,变成可以自动执行的断言。

最终这次用例验证了:

text 复制代码
点击初始化
    -> 捕获 client/init
    -> 校验响应成功

点击游客登录
    -> 捕获 oauth2/login
    -> 校验响应成功
    -> 校验返回 access_token

游客登录后
    -> 捕获 getUserById
    -> 校验响应成功

登录成功后
    -> 捕获 adjusteventrecord/save
    -> 校验 eventName = sdk_登录成功
    -> 校验响应成功

这样一来,自动化测试就不只是"页面能不能点",而是可以真正验证:

text 复制代码
SDK 是否按预期发起了正确请求
并返回了正确结果

最后总结

这次验证让我重新分清了两件事:

text 复制代码
页面断言:验证用户看到的结果
网络断言:验证 SDK 背后的真实行为

如果只是验证按钮能不能点击,页面断言可能已经够用。

但如果要测试 SDK,尤其是初始化、登录、埋点、支付、三方登录这些流程,只看页面是不够的。

因为这些功能真正重要的部分,往往都在接口请求里。

所以后续再测试其他 SDK 功能时,也可以沿用同一个思路:

text 复制代码
先操作页面
再等待目标请求
最后断言请求参数和响应数据

这样自动化测试就不只是"会点页面",而是能真正验证 SDK 的行为是否符合预期。

相关推荐
u0110225121 小时前
如何自定义查询历史记录面板的展示风格_时间轴样式设计
jvm·数据库·python
2301_769340671 小时前
HTML怎么实现快捷跳转顶部_HTML固定悬浮锚点按钮【介绍】
jvm·数据库·python
VOOHU-沃虎1 小时前
工业以太网接口的隐形门槛:网络变压器选型失当的故障分析与系统性验证
网络
yuanpan1 小时前
Python + PyAutoGUI 实战:Windows 自动化办公脚本开发入门
windows·python·自动化
m0_609160491 小时前
MySQL如何限制触发器递归调用的深度_防止触发器死循环方法
jvm·数据库·python
广州灵眸科技有限公司1 小时前
瑞芯微(EASY EAI)RV1126B yolov11-track多目标跟踪部署教程
linux·开发语言·网络·人工智能·yolo·机器学习·目标跟踪
牵牛老人1 小时前
CAN通讯实战:Qt基于周立功 USBCAN 的 CAN 总线通信开发全攻略
网络·qt·系统架构
zjy277771 小时前
Golang bcrypt如何加密密码_Golang密码加密教程【收藏】
jvm·数据库·python
曾庆睿2 小时前
【基于 RHEL 9.3 的 K8s + GitLab 全自动化部署环境搭建第一篇】
kubernetes·自动化·gitlab