H5 与小程序测试要点¶
H5 和小程序都不是“独立 App”,它们跑在别人的容器里:微信的 XWeb、iOS 的 WKWebView、支付宝的 Nebula、小程序的 JSCore/V8。容器的版本、权限、缓存、生命周期都不由你控制,同一个页面在浏览器正常,不代表在容器里正常。
以下行为基于微信 8.0.47、微信开发者工具 1.06.2401020、基础库 3.3.4、iOS 17.2 / Android 14。
一、WebView 里的 H5 调试¶
Android:chrome://inspect¶
前提是 WebView 开启了调试开关。App 自己的 WebView 需要显式打开:
然后电脑 Chrome 打开 chrome://inspect/#devices,勾选 Discover network targets,设备出现在列表里才能 inspect。看不到设备,按顺序排查:
- USB 调试是否授权(
adb devices是否显示 device 而不是 unauthorized)。 - App 是否为 debuggable 包(release 包即使调用也不生效)。
- 微信/企业微信等第三方容器是否开启了调试模式。
- 是否被国产 ROM 的“USB 安装/调试”二次开关拦住。
微信 Android 端历史上用 X5 内核,现为 XWeb。部分版本可以用 http://debugxweb.qq.com/?inspector=true 打开调试开关,但新版本已逐步收敛,不能把它当作稳定手段。生产排查更可靠的是在测试环境注入 vConsole。
iOS:Safari 远程调试¶
iPhone 设置 → Safari → 高级 → 网页检查器(iOS 16.4 起入口调整,部分容器还需在 App 内单独允许),然后用数据线连接 Mac,Safari → 开发 → 选择设备 → 选择页面。
要点:
- 必须用数据线,无线调试对 WebView 不稳定。
- 页面列表里显示的是
AppName - host/path,同名页面多时容易选错。 - WKWebView 的 console 只在选中元素后才有输出上下文,页面跳转后调试窗口会断开,需要重新选。
- 如果列表为空,先确认 macOS Safari 的“开发”菜单已启用(设置 → 高级 → 显示网页开发者功能)。
兜底:vConsole¶
测试环境注入,不依赖任何容器开关:
<script src="https://cdn.jsdelivr.net/npm/vconsole@3.15.1/dist/vconsole.min.js"></script>
<script>
if (location.hostname !== 'www.example.com') {
window.vConsole = new window.VConsole({ defaultPlugins: ['system', 'network', 'element', 'storage'] });
}
</script>
vConsole 能看 console、Network、Storage、DOM,但看不到原生层错误(WebView 崩溃、SSL 握手失败、App 生命周期),这类问题仍然要靠 adb logcat 或 Xcode 控制台。
真实报错示例,看到就知道不是前端代码问题:
net::ERR_CLEARTEXT_NOT_PERMITTED # Android 9+ 默认禁止 HTTP 明文
net::ERR_CONNECTION_REFUSED # 代理/端口没通
net::ERR_SSL_PROTOCOL_ERROR # 证书链或 TLS 版本不匹配
第一条尤其常见:测试环境用 http://10.0.2.2:8080,Android 9 起直接拒绝。要么全站 HTTPS,要么在 debug manifest 里配 android:usesCleartextTraffic="true" 或 networkSecurityConfig,不要把它带进 release。
二、小程序的三种调试形态¶
| 形态 | 入口 | 用途 | 注意 |
|---|---|---|---|
| 模拟器 | 开发者工具 | 日常开发 | 不能代表真机键盘、字体、性能 |
| 真机调试 | 工具右上角“真机调试” | 排查真机差异 | 支持 vConsole 与断点,但会占用调试包 |
| 体验版 | 上传后设为体验版 | 测试/产品验收 | 需在后台添加体验成员 |
| 开发版 | 预览二维码 | 快速验证 | 二维码有效期短,需重新生成 |
# 微信开发者工具 CLI(Windows 默认安装路径)
"C:\Program Files (x86)\Tencent\微信web开发者工具\cli.bat" islogin
"C:\Program Files (x86)\Tencent\微信web开发者工具\cli.bat" preview \
--project "D:\repo\miniprogram" --qr-output "D:\tmp\qr.png"
# 上传体验版
"C:\Program Files (x86)\Tencent\微信web开发者工具\cli.bat" upload \
--project "D:\repo\miniprogram" -v 2.3.0 -d "fix: 订单页空态"
真机调试与模拟器的差异,必须逐项过:基础库版本、键盘行为、rpx 换算、字体渲染、setData 性能、扫码/相机/定位等原生能力。开发者工具里能跑的用例,最多只能证明逻辑没写错。
三、登录态与授权¶
登录链路¶
测试点:
- code 重复使用必须失败(后端应报
invalid code/40163),不能出现“一个 code 换两次 token”。 - code 过期(>5 分钟)时的提示与重试。
session_key绝不能下发到前端,抓包确认响应体里没有。- token 过期后的静默续期:
wx.checkSession失败 → 重新wx.login,不能弹登录页打断用户。 - 多端登录:同一 openid 在另一台设备登录,旧设备是否被踢、被踢后是否清理本地态。
- 退出登录后:本地 storage、页面栈、订阅消息状态是否清干净。
用户信息授权的变化¶
wx.getUserProfile 自 2022-10 起被回收,返回的是匿名数据(灰色头像、微信用户)。现在获取头像昵称要用填写能力:
<button open-type="chooseAvatar" bind:chooseavatar="onChooseAvatar">选择头像</button>
<input type="nickname" placeholder="请输入昵称" />
手机号获取也有门槛:getPhoneNumber 需要已认证的企业主体,且手机号快速验证组件按次收费(约 0.03 元/次),个人主体不可用。测试前先确认账号资质,否则会把“能力不可用”误报成缺陷。
四、页面栈的 10 层限制¶
小程序页面栈最多 10 层,超出后 navigateTo 直接失败,真实报错:
| API | 行为 | 页面栈变化 |
|---|---|---|
wx.navigateTo |
保留当前页,打开新页 | +1,上限 10 |
wx.redirectTo |
关闭当前页,打开新页 | 不变 |
wx.reLaunch |
关闭所有页面 | 重置为 1 |
wx.switchTab |
只跳 tabBar 页,关闭其他页 | 重置为 1 |
wx.navigateBack |
返回,delta 可多级 |
-n |
// 怎么看当前栈深
const depth = getCurrentPages().length;
console.log('stack depth:', depth);
// 防御式跳转:接近上限时改用 redirectTo
function go(url) {
if (getCurrentPages().length >= 9) {
wx.redirectTo({ url });
} else {
wx.navigateTo({ url });
}
}
案例 1:活动页反复跳转后点不动。 活动落地页 → 商品详情 → 再来一个相似商品 → 详情……用户连续点 10 次后,所有跳转静默失败,页面毫无反应。根因是详情页之间用 navigateTo 互相打开,栈很快打满。修复是“详情 → 详情”改用 redirectTo,并在失败回调里统一兜底 wx.showToast,避免静默。
页面栈还会带来两个隐性问题:navigateBack 后上一页不会重新 onLoad,只触发 onShow,数据不会自动刷新;switchTab 会销毁非 tab 页,返回时状态全丢。凡是“返回后数据没更新”的缺陷,先查生命周期,再查缓存。
五、setData 的性能边界¶
setData 是逻辑层到视图层的跨线程通信,代价很高。硬限制:单次调用数据量不能超过 1MB,超过会失败并报:
即便没超限,频繁调用也会造成卡顿。常见错误写法与修正:
// 错误:每次全量传整个列表
this.setData({ list: this.data.list });
// 正确:只传变更路径
const i = 12;
this.setData({ [`list[${i}].checked`]: true });
// 错误:循环里连续 setData
for (let i = 0; i < 100; i++) this.setData({ [`items[${i}]`]: i });
// 正确:合并成一次
const patch = {};
for (let i = 0; i < 100; i++) patch[`items[${i}]`] = i;
this.setData(patch);
长列表的正确做法是分页 + 虚拟列表(只渲染可视区 ± 1 屏),而不是一次渲染 500 条。1MB 大约相当于 5000 个中文字符或 2000 条简单对象,商品列表带图片 URL、SKU、标签时,200 条就可能逼近上限。
验证方法:开发者工具 → 调试器 → Performance,录一次滚动与翻页,看 setData 的调用次数、传输大小、耗时。真机上再用“性能面板”对比,模拟器的数字偏乐观。
六、分包加载¶
微信小程序体积限制(以后台当前配置为准):主包 ≤ 2MB,单个分包 ≤ 2MB,总包 ≤ 20MB(部分类目已放宽到 30MB)。超限直接上传失败。
{
"pages": ["pages/index/index", "pages/login/login"],
"subPackages": [
{ "root": "packageActivity", "pages": ["pages/detail/detail"] },
{ "root": "packageOrder", "pages": ["pages/list/list"], "independent": false }
],
"preloadRule": {
"pages/index/index": { "network": "all", "packages": ["packageOrder"] }
}
}
| 概念 | 说明 | 测试点 |
|---|---|---|
| 主包 | 启动必加载 | 控制在 2MB 内,别把大图打进来 |
| 普通分包 | 进入时下载 | 首次进入有白屏/loading,弱网更明显 |
| 独立分包 | 不依赖主包,可独立运行 | 不能使用主包组件与全局状态 |
| 分包预下载 | 提前下载指定分包 | 弱网下是否拖慢主流程 |
| 分包异步化 | require.async 跨包引用 |
低版本基础库是否支持 |
案例 2:活动页白屏 3 秒。 活动分包 1.8MB,包含 6 张未压缩的 banner 图。4G 下首次进入平均 2.8s 白屏,用户以为页面挂了。根因是图片直接打在代码包里,且没有骨架屏。修复是把图片移到 CDN、分包降到 400KB,并加上骨架屏与失败重试。分包体积要用开发者工具的“代码依赖分析”看,不是靠猜。
七、扫码与分享进入的参数¶
扫码¶
| 码类型 | 参数位置 | 限制 |
|---|---|---|
| 普通链接二维码 | options.q(完整 URL,需 decode) |
URL 长度受限 |
小程序码(getUnlimited) |
options.scene |
scene 最多 32 个可见字符 |
| 开发者工具编译模式 | 自定义启动参数 | 仅本地生效 |
Page({
onLoad(options) {
let scene = options.scene ? decodeURIComponent(options.scene) : '';
// 常见格式 "id=88&from=qr"
const params = Object.fromEntries(new URLSearchParams(scene));
this.loadDetail(params.id, params.from);
},
});
三个容易踩的点:scene 是 URL 编码过的,不 decode 会拿到 %3D;scene 只有 32 字符,塞长参数会被截断;扫码进入时用户可能未登录,登录后要恢复原始参数,不能直接 reLaunch 到首页。
分享¶
Page({
onShareAppMessage() {
return {
title: '这个商品不错',
path: `/pages/detail/detail?id=${this.data.id}&from=share`,
imageUrl: this.data.cover,
};
},
onShareTimeline() {
return { title: '这个商品不错', query: `id=${this.data.id}&from=timeline` };
},
});
分享进入要测:分享给好友、分享到群、朋友圈(仅 Android 支持 onShareTimeline)、分享卡片标题与图片、冷启动与热启动参数、未登录拦截后回跳、参数被篡改(id=999999)时的兜底。
案例 3:分享链接打开后总是首页。 用户从群卡片进入,onLoad 里拿到了 id,但页面先执行了“未登录 → reLaunch('/pages/login/login')”,登录成功后统一跳首页,参数丢失。修复是把目标页与参数存进 storage,登录成功后 redirectTo 回目标页;同时处理“登录页本身被分享”的情况。
八、支付与回调¶
wx.requestPayment({
timeStamp: res.timeStamp,
nonceStr: res.nonceStr,
package: res.package,
signType: 'RSA',
paySign: res.paySign,
success: () => { /* 只是客户端调起成功,不代表到账 */ },
fail: (e) => { /* cancel / system error */ },
});
必须建立的认知:success 只代表用户完成了支付动作,订单状态必须以服务端回调或主动查单为准。真实失败文案:
requestPayment:fail cancel # 用户取消
requestPayment:fail system error # 签名/参数/商户配置问题
requestPayment:fail (errCode: -1) # 调起失败,常见于签名错误
测试矩阵:
| 场景 | 操作 | 预期 |
|---|---|---|
| 正常支付 | 余额充足 | 回调后订单变已支付,幂等 |
| 用户取消 | 支付面板返回 | 订单保持待支付,可再次发起 |
| 支付成功回调延迟 | 断网/延迟回调 | 订单页显示“确认中”,轮询后更新 |
| 回调重复 | 重放 notify | 只更新一次,不重复发货 |
| 回调乱序 | 先收到退款再收到支付 | 状态机不回退 |
| 金额篡改 | 改前端传参 | 服务端以订单金额为准,拒绝 |
| 重复支付 | 连续发起两次 | 第二笔被拦截或自动退款 |
案例 4:支付成功但订单一直待支付。 用户扣款成功,订单页 30 分钟仍是“待支付”。查日志发现支付回调地址配置成了内网域名,微信侧请求超时后重试 3 次全部失败。修复是回调地址用公网 HTTPS、回调处理必须幂等,并加一个“主动查单”的兜底任务(下单后每 5s 查一次,最多 60s)。验收时要故意让回调失败一次,确认兜底能补上。
另外,iOS 小程序不允许虚拟支付(购买课程、会员、道具等),这是平台规则,不是技术缺陷,测试前要确认业务类型。
九、缓存与版本更新¶
小程序代码包由微信缓存,用户不主动重启可能长期停留在旧版。必须显式处理更新:
const updateManager = wx.getUpdateManager();
updateManager.onCheckForUpdate((res) => {
console.log('hasUpdate:', res.hasUpdate);
});
updateManager.onUpdateReady(() => {
wx.showModal({
title: '更新提示',
content: '新版本已准备好,是否重启应用?',
success: (r) => { if (r.confirm) updateManager.applyUpdate(); },
});
});
updateManager.onUpdateFailed(() => {
wx.showModal({ title: '更新失败', content: '请删除小程序后重新打开' });
});
注意:onCheckForUpdate 只在冷启动时触发,热启动不会检查。所以“发版后用户仍看到旧页面”往往不是缓存没清,而是用户根本没杀过小程序。
本地存储也有硬限制:单个 key ≤ 1MB,总容量 ≤ 10MB,超出时 wx.setStorage 失败。缓存要带版本号,升级时清理旧结构:
const CACHE_VERSION = 3;
if (wx.getStorageSync('cacheVersion') !== CACHE_VERSION) {
wx.clearStorageSync();
wx.setStorageSync('cacheVersion', CACHE_VERSION);
}
H5 侧的对应问题是 index.html 被强缓存:资源加了 hash 但 HTML 没加,发版后仍引用旧 JS,控制台报 404 或 Unexpected token '<'。HTML 必须 Cache-Control: no-cache,静态资源用内容 hash + 长缓存。
十、iOS 键盘与 fixed 布局¶
iOS 的键盘是覆盖在 WebView 之上的,不会改变视口高度,position: fixed 元素不会自动避让。表现有三种:
- 底部输入框被键盘遮住,用户看不见自己在打字。
- 底部按钮被顶起,键盘收起后停留在半空,不再回到底部。
- 页面整体被上推,顶部标题栏滚出屏幕。
小程序侧的可用手段:
<view class="page" style="--kb-height:{{keyboardHeight}}px">
<input
bindfocus="onFocus"
bindblur="onBlur"
adjust-position="{{false}}"
cursor-spacing="20"
/>
</view>
Page({
data: { keyboardHeight: 0 },
onFocus(e) {
// e.detail.height 是键盘高度(px)
this.setData({ keyboardHeight: e.detail.height || 0 });
},
onBlur() {
this.setData({ keyboardHeight: 0 });
},
});
.footer {
position: fixed;
left: 0; right: 0;
bottom: calc(env(safe-area-inset-bottom) + 8px);
transition: transform .2s;
}
.kb-open .footer { transform: translateY(calc(-1 * var(--kb-height, 0px))); }
adjust-position 默认是 true,会自动把页面往上推,但只对输入框所在位置生效;一旦页面里有 fixed 元素,就会出现“推上去下不来”的错位。把它设为 false 自己控制,反而更稳定。H5 在微信 WebView 里没有 bindfocus,只能监听 visualViewport 的 resize:
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => {
const kb = window.innerHeight - window.visualViewport.height;
document.documentElement.style.setProperty('--kb-height', `${Math.max(0, kb)}px`);
document.body.classList.toggle('kb-open', kb > 100);
});
}
必须测的机型组合:iOS 17 微信、iOS 16 微信、Android 14 微信、企业微信。Android 的 adjustResize 行为与 iOS 完全不同,同一套 CSS 往往只能保一头。
十一、HTTPS 与跨域¶
小程序¶
wx.request的域名必须在“开发管理 → 服务器域名”里配置,且必须 HTTPS、已备案。- 不支持 IP、localhost、非标准端口(部分能力)。
- 域名有修改次数限制,测试环境与生产环境要提前规划,不要临时加。
- 开发者工具可勾选“不校验合法域名”,这个开关只对工具生效,真机一定失败。
真实报错:
request:fail url not in domain list
request:fail ssl hand shake error
request:fail -102:net::ERR_CONNECTION_REFUSED
第一条是最高频的“工具能跑、真机不能跑”原因。提测前先在三端各跑一次登录,确认域名配置完整。
H5 跨域¶
Access to fetch at 'https://api.example.com/user' from origin 'https://h5.example.com'
has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header
in the response must not be the wildcard '*' when the request's credentials mode is 'include'.
带 Cookie 的跨域请求,服务端必须返回具体 Origin,不能是 *,同时要 Access-Control-Allow-Credentials: true;预检 OPTIONS 必须返回 204/200,且允许 Content-Type、Authorization 等实际用到的头。Cookie 还要 SameSite=None; Secure,否则微信 WebView 里直接不带。
# 一次看清预检结果
curl -i -X OPTIONS 'https://api.example.com/user' \
-H 'Origin: https://h5.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,authorization'
十二、小程序隐私接口变更¶
2023-09-15 起,涉及用户信息的接口必须先获得隐私授权。app.json 需要声明:
{
"requiredPrivateInfos": ["getLocation", "chooseLocation", "chooseAddress"],
"__usePrivacyCheck__": true
}
未声明就调用,真实报错:
隐私授权有两条路径:
- 主动式:在调用敏感接口前
wx.requirePrivacyAuthorize,失败则引导。 - 被动式:注册
wx.onNeedPrivacyAuthorization,由平台在首次调用时触发,开发者弹自定义隐私弹窗,再调resolve({ event: 'agree' })。
wx.onNeedPrivacyAuthorization((resolve) => {
wx.showModal({
title: '隐私保护提示',
content: '使用定位前请阅读并同意《隐私保护指引》',
confirmText: '同意',
success: (r) => resolve({ event: r.confirm ? 'agree' : 'disagree' }),
});
});
测试点:首次调用是否弹、拒绝后是否还能用非敏感功能、同意后是否记住、隐私政策更新后是否重新征得同意、wx.openPrivacyContract 能否打开、注销后是否清理。在隐私弹窗出现之前,不允许初始化任何采集 SDK,这条与 App 的合规要求一致。
十三、miniprogram-automator 自动化¶
安装与启动:
// tests/order.test.js
const automator = require('miniprogram-automator');
(async () => {
const miniProgram = await automator.launch({
cliPath: 'C:/Program Files (x86)/Tencent/微信web开发者工具/cli.bat',
projectPath: 'D:/repo/miniprogram',
});
// 拦截登录请求,注入固定 token,避免依赖真实账号
await miniProgram.mockWxMethod('login', { code: 'mock-code' });
const page = await miniProgram.reLaunch('/pages/order/list');
await page.waitFor(800);
const items = await page.$$('.order-item');
if (items.length !== 3) throw new Error(`期望 3 条订单,实际 ${items.length}`);
await items[0].tap();
await page.waitFor(500);
const detail = await miniProgram.currentPage();
const title = await detail.data('title');
console.log('detail title:', title);
await miniProgram.close();
})();
常用能力:
| API | 用途 |
|---|---|
automator.launch |
启动工具并打开项目 |
automator.connect |
连接已开启自动化端口的工具(--auto-port) |
miniProgram.reLaunch / navigateTo |
页面跳转 |
page.$ / page.$$ |
选择元素 |
element.tap / input |
交互 |
page.data() |
读取页面 data |
page.setData() |
直接改数据,构造边界态 |
miniProgram.mockWxMethod |
mock wx.login、wx.request |
miniProgram.callWxMethod |
调用 getSystemInfoSync 等 |
局限要说清楚:automator 跑在开发者工具的模拟器里,测不到真机键盘、性能、扫码、支付。它适合回归“页面能否打开、数据是否渲染、跳转是否正确”,真机链路仍需手工或云真机补。
十四、验收清单¶
| 类别 | 验收项 | 判定 |
|---|---|---|
| 容器 | 微信/企业微信/支付宝/系统浏览器 | 首屏、滚动、返回键一致 |
| 调试 | vConsole、Safari、chrome://inspect | 至少一条链路可用 |
| 登录 | code 一次性、过期、续期、多端 | 无重复换 token,无 session_key 泄露 |
| 授权 | 头像昵称、手机号、定位 | 拒绝后有降级路径 |
| 页面栈 | 连续 navigateTo 10 次 | 不静默失败,接近上限有策略 |
| setData | 长列表、频繁更新、1MB 边界 | 无超限报错,帧率达标 |
| 分包 | 主包体积、分包加载、预下载 | 弱网有 loading,失败可重试 |
| 扫码/分享 | 冷启/热启/未登录/非法参数 | 参数不丢,未知参数有兜底 |
| 支付 | 取消/成功/回调延迟/重复回调 | 订单状态最终一致 |
| 更新 | 冷启动检查、强制更新、缓存版本 | 旧版可被替换,缓存可清理 |
| 键盘 | iOS/Android 输入框与 fixed 底栏 | 不遮挡、收起后归位 |
| 网络 | 域名白名单、HTTPS、CORS | 真机与工具行为一致 |
| 隐私 | 声明、弹窗时机、拒绝后行为 | 拒绝后不采集 |