跳转至

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 需要显式打开:

if (BuildConfig.DEBUG) {
    WebView.setWebContentsDebuggingEnabled(true)   // API 19+
}

然后电脑 Chrome 打开 chrome://inspect/#devices,勾选 Discover network targets,设备出现在列表里才能 inspect。看不到设备,按顺序排查:

  1. USB 调试是否授权(adb devices 是否显示 device 而不是 unauthorized)。
  2. App 是否为 debuggable 包(release 包即使调用也不生效)。
  3. 微信/企业微信等第三方容器是否开启了调试模式。
  4. 是否被国产 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 控制台。

# Android 侧同时抓 WebView 日志
adb logcat -s chromium:* WebViewFactory:* com.example.app:*

真实报错示例,看到就知道不是前端代码问题:

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 性能、扫码/相机/定位等原生能力。开发者工具里能跑的用例,最多只能证明逻辑没写错。

三、登录态与授权

登录链路

wx.login → code(5 分钟有效、一次性)→ 后端 code2Session
→ openid + session_key(只留后端)→ 自签 token 返回小程序

测试点:

  1. code 重复使用必须失败(后端应报 invalid code / 40163),不能出现“一个 code 换两次 token”。
  2. code 过期(>5 分钟)时的提示与重试。
  3. session_key 绝不能下发到前端,抓包确认响应体里没有。
  4. token 过期后的静默续期:wx.checkSession 失败 → 重新 wx.login,不能弹登录页打断用户。
  5. 多端登录:同一 openid 在另一台设备登录,旧设备是否被踢、被踢后是否清理本地态。
  6. 退出登录后:本地 storage、页面栈、订阅消息状态是否清干净。

用户信息授权的变化

wx.getUserProfile 自 2022-10 起被回收,返回的是匿名数据(灰色头像、微信用户)。现在获取头像昵称要用填写能力:

<button open-type="chooseAvatar" bind:chooseavatar="onChooseAvatar">选择头像</button>
<input type="nickname" placeholder="请输入昵称" />

手机号获取也有门槛:getPhoneNumber 需要已认证的企业主体,且手机号快速验证组件按次收费(约 0.03 元/次),个人主体不可用。测试前先确认账号资质,否则会把“能力不可用”误报成缺陷。

四、页面栈的 10 层限制

小程序页面栈最多 10 层,超出后 navigateTo 直接失败,真实报错:

navigateTo:fail webview count limit exceed
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,超过会失败并报:

setData 数据传输长度为 1153433,已经超过 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 元素不会自动避让。表现有三种:

  1. 底部输入框被键盘遮住,用户看不见自己在打字。
  2. 底部按钮被顶起,键盘收起后停留在半空,不再回到底部。
  3. 页面整体被上推,顶部标题栏滚出屏幕。

小程序侧的可用手段:

<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
}

未声明就调用,真实报错:

getLocation:fail the api need to be declared in the requiredPrivateInfos field in app.json

隐私授权有两条路径:

  1. 主动式:在调用敏感接口前 wx.requirePrivacyAuthorize,失败则引导。
  2. 被动式:注册 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 自动化

安装与启动:

npm i -D miniprogram-automator jest
# 工具需已登录,且开启“安全模式 → CLI/HTTP 调用”
// 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 真机与工具行为一致
隐私 声明、弹窗时机、拒绝后行为 拒绝后不采集

相关阅读