上一篇文章里,我验证的是:
同一个 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
页面显示成功,不代表网络请求一定正确。
比如:
- 登录接口请求成功了,但没有返回
access_token - 用户信息接口失败了,但页面没有暴露出来
- 埋点接口根本没有请求
- 埋点接口请求了,但
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 的行为是否符合预期。