@mptool/mock
wx API 模拟,用于在 Node.js / vitest 等环境中直接运行小程序代码。它既实现 storage / 文件系统 / 路由 / 权限 / 网络 / 系统信息等状态语义,也提供 mockWx 控制面来注入失败、配置用户决策与预置状态。
安装
pnpm add -D @mptool/mock快速开始
在测试环境的 setup 文件中引入一次(例如 vitest.setup.ts):
import "@mptool/mock";引入后会:
- 在
globalThis上注册Page/App/Component/Behavior/getCurrentPages - 安装全局
wx对象,并导出同一份wx - 提供
mockWx控制面与事件触发能力
// example.spec.ts
import "@mptool/mock";
import { mockWx, wx } from "@mptool/mock";
import { beforeEach, describe, expect, it } from "vitest";
describe("example", () => {
beforeEach(() => {
mockWx.reset();
});
it("reads storage", async () => {
wx.setStorageSync("token", "abc");
await expect(wx.getStorage({ key: "token" })).resolves.toMatchObject({ data: "abc" });
});
});测试隔离
mockWx.reset() 会恢复默认环境(storage / FSM / 事件 / 页面栈 / system / network / USER_DATA_PATH 等)并还原被替换的 wx 方法;推荐在 beforeEach 中调用。局部状态可以用 scope() 自动还原:
const scope = mockWx.scope();
mockWx.network({ networkType: "4g" });
// ...
scope.restore(); // 只还原 scope 内的改动也可以在支持 afterEach 的测试框架里使用 mockWx.autoRestore(afterEach);mockWx.run(callback) 会在执行完后自动还原。
调用约定
回调式 API
未传回调时返回 Promise,传了 success / complete 回调时异步触发:
await wx.showToast({ title: "hi" }); // Promise 风格
wx.showToast({
title: "hi",
success: () => console.log("done"), // 回调风格
});返回任务(Task)的 API
与真机保持一致,wx.request 始终返回 RequestTask,不支持 Promise 风格调用(真机上 wx.request 不会返回 Promise)。可以通过返回的任务中断请求:
const task = wx.request({
url: "https://example.com/api",
success: (res) => console.log(res.statusCode),
fail: ({ errMsg }) => console.error(errMsg),
});
// 中断请求后,fail 会收到 errMsg "request:fail abort"
task.abort();网络失败、超时和自定义状态码可以用
mockWx的fail()/respond()/route()构造;respond()/route()支持delay模拟慢响应(mock 不会按option.timeout自动超时;需要超时分支可用mockWx.failNext("request", { type: "timeout" }))。
任务还提供 onHeadersReceived / offHeadersReceived(在 success 之前触发)以及 onChunkReceived / offChunkReceived(mock 不会产生数据分片,仅保证接口可用)。
wx.downloadFile/wx.uploadFile同样返回任务对象;mock 不产生真实进度(进度回调不触发),abort可用,失败/取消通过mockWx.failNext注入。
同步 API
wx.getWindowInfo、wx.getStorageSync 等同步 API 直接返回对象。
常用场景
以下是最常见的测试写法;每类行为的完整语义在后续章节展开。
失败注入
mockWx.failNext("getNetworkType", { type: "network" });
await expect(wx.getNetworkType({})).rejects.toMatchObject({
errMsg: expect.stringContaining("fail"),
});
const handle = mockWx.fail("request", { type: "timeout" });
// ...
handle.dispose();用户决策 / 权限
mockWx.user({ modal: "cancel", actionSheet: 1, payment: "cancel" });
mockWx.auth({ "scope.camera": false });
mockWx.authorize("deny");
mockWx.clipboard("copied");网络 / 系统信息
mockWx.system({ platform: "ios", brand: "Apple", windowWidth: 414, windowHeight: 896 });
mockWx.network({ networkType: "4g", signalStrength: 42, weakNet: true });
mockWx.system({ userDataPath: "wxfile://usr" });Storage
wx.setStorageSync("key", { a: 1 });
mockWx.storage({ limitSize: 1 }); // KB,便于构造超限
mockWx.failNext("setStorage", { type: "quota" });文件系统(FSM)
const fs = wx.getFileSystemManager();
fs.mkdirSync(`${wx.env.USER_DATA_PATH}/tmp`, true);
fs.writeFileSync(`${wx.env.USER_DATA_PATH}/tmp/a.txt`, "hello");
fs.readFileSync(`${wx.env.USER_DATA_PATH}/tmp/a.txt`, "utf-8"); // "hello"页面栈
Page({ route: "/pages/detail", onLoad() {} });
await wx.navigateTo({ url: "/pages/detail?id=1" });
getCurrentPages(); // [{ route: "/pages/detail", ... }]
await wx.navigateBack();事件
const listener = vi.fn();
wx.onThemeChange(listener);
mockWx.emit("themeChange", { theme: "dark" });
// 导航失败会自动触发 onPageNotFound
wx.onPageNotFound(listener);
await wx.navigateTo({ url: "/pages/missing" }).catch(() => {});WXML 夹具 / SelectorQuery
mockWx.node("#card", { left: 0, top: 0, width: 300, height: 120 });
wx.createSelectorQuery()
.select("#card")
.boundingClientRect((rect) => {
console.log(rect); // { left: 0, top: 0, width: 300, height: 120, ... }
})
.exec();控制面 mockWx(强制失败与响应注入)
除了直接替换 wx.xxx,还可以用 mockWx 精确控制某个 API 在何时、以什么方式失败或返回自定义结果。wx 仍然是普通的可变对象:直接赋值(如 wx.request = vi.fn())优先级最高,会绕过注入规则。
注入失败
import { mockWx, wx } from "@mptool/mock";
// 只失败下一次
mockWx.failNext("getNetworkType", { type: "network" });
// 一直失败,直到 dispose()
const handle = mockWx.fail("request", { type: "timeout" });
// ... 测试结束后
handle.dispose();
// 只对特定请求生效
mockWx.fail(
"request",
{ type: "network" },
{ match: (option) => (option as { url: string }).url.includes("/pay") },
);
// 指定失败次数:前两次失败,第三次正常
mockWx.fail("getStorageInfo", { type: "network" }, { times: 2 });
// 依次失败:第一次 network,第二次 timeout,之后恢复正常
mockWx.failSequence("getNetworkType", [{ type: "network" }, { type: "timeout" }]);
// 同步 API 会直接抛错
mockWx.failNext("getStorageSync", { type: "quota" });
wx.getStorageSync("key"); // throws: setStorage:fail exceed storage max size支持的失败类型:
| 类型 | 说明 | 默认 errMsg |
|---|---|---|
network | 断网 / DNS 失败 | <api>:fail |
timeout | 请求超时 | <api>:fail timeout |
abort | 请求被中断 | <api>:fail abort |
quota | storage 配额满 | setStorage:fail exceed storage max size |
noent | 文件 / 目录不存在 | <api>:fail no such file or directory <path>(errCode: 1300002) |
permission | 权限被拒绝 | authorize:fail auth deny / <api>:fail permission denied(errCode: 1300014) |
cancel | 用户取消 | showActionSheet:fail cancel 等 |
fail | 自定义失败 | 自定义 errMsg |
throw | 同步 API 抛出指定 Error | - |
回调式 API:
fail与complete会被调用;未传回调的 Promise 风格调用会 reject。 任务式 API(wx.request/wx.downloadFile/wx.uploadFile/wx.connectSocket):返回任务对象,失败通过fail回调投递。 管理器 API 不支持失败注入,但可以用respond()替换返回的管理器对象;事件 / 无参 API 的规则会在注册时直接报错。
注入响应
// 回调式 API 的成功结果
mockWx.respond("getNetworkType", { networkType: "none" });
// 同步 API 的返回值
mockWx.respond("getWindowInfo", { windowWidth: 320 });
// 支持异步 responder
mockWx.respond("getStorageInfo", async () => ({ currentSize: 0, keys: [], limitSize: 10240 }));
// 按 URL + method 路由
mockWx.route({
"GET https://api.example.com/user": { statusCode: 200, data: { name: "hope" } },
"POST https://api.example.com/pay": { statusCode: 502, data: { error: "bad gateway" } },
// 正则(可带 flags)
"/^https:\\/\\/api\\.example\\.com\\/v\\d+\\//": { statusCode: 204 },
// 单个响应可指定延迟;也可以直接路由到失败
"GET https://api.example.com/slow": { delay: 100, statusCode: 200 },
"GET https://api.example.com/offline": { type: "timeout" },
});
// 管理器 API 可以整体替换返回值(失败注入不支持管理器)
mockWx.respond("getUpdateManager", fakeUpdateManager);未命中的请求会落回 mock 的默认响应(wx.request 为 statusCode: 200、空 body)。
调用记录
wx.showToast({ title: "hi" });
wx.showToast({ title: "again" });
mockWx.calls("showToast"); // [[{ title: "hi" }], [{ title: "again" }]]
mockWx.clearCalls("showToast");
mockWx.clearCalls(); // 清空全部作用域与自动还原
import { afterEach } from "vitest";
// 方式一:每个测试后自动还原(在测试文件顶层调用一次;需显式传入 vitest 的 afterEach)
mockWx.autoRestore(afterEach);
// 方式二:手动作用域
const scope = mockWx.scope();
mockWx.failNext("request", { type: "network" });
// ...
scope.restore();
// 方式三:异步作用域,抛出异常也会还原
await mockWx.run(async () => {
mockWx.failNext("request", { type: "network" });
// ...
});scope() / autoRestore() / run() 会一起还原:注入规则、调用记录、storage、内存文件系统、事件监听、页面栈、wx.env,以及下面「状态预置」的内容。
mockWx.reset() 会做硬重置:清空规则、调用记录、storage、内存文件系统、事件监听、页面栈,并把环境与状态预置恢复为默认值。
状态预置
mockWx.storage({ limitSize: 1024 });
mockWx.auth({ "scope.camera": false });
mockWx.authorize("deny");
mockWx.user({ modal: "cancel" });
mockWx.system({ platform: "android" });
mockWx.network({ networkType: "none" });
mockWx.clipboard("copied");
mockWx.login({ code: "mock-code", sessionValid: false });
mockWx.pages([{ route: "/pages/main" }]);
mockWx.file("data/file.txt", "content");
mockWx.dir("data");
mockWx.compat({ storageResolveMissingKey: true });这些配置都会立即被对应的 mock 行为读取:
auth/authorize/user影响权限与 UI,system/network影响系统信息与网络 API,clipboard/login影响剪贴板与登录,pages/file/dir直接预置页面栈与文件系统;scope()/reset()可还原。
Storage 语义(真机对齐)
@mptool/mock 的 storage 按微信开发者工具实测行为工作(2026-10 实测,SDK 3.17.4 / libVersion 3.5.0 结论一致):
- 按值序列化:写入时序列化、读取时反序列化。修改原对象不会影响已存数据,每次读取都得到新对象;
undefined可以存取且不占 value 字节;循环引用等无法序列化的值会失败。顶层Date保持Date(instanceof Date、getTime()正常);嵌套在对象/数组里的Date会变成 ISO 字符串(与真机一致)。 - 缺 key:
wx.getStorage默认fail(errMsg: "getStorage:fail data not found"),complete同样收到该文案。wx.getStorageSync默认返回""。wx.batchGetStorage默认 success(errMsg: "batchGetStorage:ok"),缺失位置为null(结果字段是dataList,不是data)。
- 配额:开发者工具只强制总量 10 MB,单 key 1 MB 并不强制(2 MB 单 key 可写入);超限报
errMsg: "setStorage:fail exceed storage max size 10Mb"(同步抛错在工具内会带APP-SERVICE-SDK:前缀)。batchSetStorage会先整体校验,失败时不会写入任何一项。单 key 限额可用mockWx.storage({ keySize })显式开启。 - errMsg:成功回调统一为
<api>:ok(如"setStorage:ok"/"getStorage:ok"/"batchGetStorage:ok"/"clearStorage:ok")。 - currentSize:按 key 名 + 序列化 value 的 UTF-8 字节计算、向上取整到 KB;空 storage 也返回
1(开发者工具基线)。limitSize默认10240(KB)。
可以用 mockWx.storage() 调整(全部单位均为 KB):
// 更小的总量配额,便于构造自然超限
mockWx.storage({ limitSize: 1 });
// 显式开启单 key 限额(默认不强制,模拟开发者工具行为)
mockWx.storage({ keySize: 1024 });
// 缺 key 的替代语义(默认值见上)
mockWx.storage({
missingKey: "resolve", // getStorage 缺 key 走 success
missingKeySync: "undefined", // getStorageSync 缺 key 返回 undefined
batchMissingKey: "fail", // batchGetStorage 缺 key 改为 fail
});missingKey: "resolve" 时 wx.getStorage 成功返回 { data: undefined, errMsg: "getStorage:ok" };missingKeySync: "undefined" 时 wx.getStorageSync 返回 undefined。
旧行为兼容(0.x 过渡)
升级 mock 后如需保留「缺 key 走 success + undefined」的旧行为,可开启 compat 开关:
mockWx.compat({ storageResolveMissingKey: true });开启后,wx.getStorage 缺 key 时成功返回 { data: undefined, errMsg: "" },wx.getStorageSync 返回 undefined,batchGetStorage 缺失位置为 undefined(旧形态;结果字段仍为修正后的 dataList)。该开关将在 1.0 移除。
失败注入
// 强制配额失败 / 缺文件失败
mockWx.failNext("setStorage", { type: "quota" });
await expect(wx.setStorage({ key: "k", data: "v" })).rejects.toMatchObject({
errMsg: "setStorage:fail exceed storage max size 10Mb",
});
mockWx.failNext("getStorage", { type: "noent" });
await expect(wx.getStorage({ key: "k" })).rejects.toMatchObject({ errCode: 1300002 });自然失败:同步 API 抛出带
errMsg的Error;异步 API 未传回调时 reject 普通对象{ errMsg }(与开发者工具一致),传了回调时调用fail+complete。
已知开发者工具差异
- 开发者工具里
wx.batchSetStorage(异步版)会失败并报"batchSetStorage:fail An object could not be cloned.";mock 不模拟该工具缺陷,异步批量写入正常工作(batchSetStorageSync在工具里正常)。 - 开发者工具对超限的写入会留下一个空/部分 key 条目;mock 不模拟该残缺状态,超限写入不会落盘。
UI / 权限 / 用户决策
mockWx 可以配置用户选择,并查询当前 UI 状态(旧默认行为保持不变:showModal 默认确认、showActionSheet 默认第 0 项、authorize 默认允许、剪贴板默认空)。
用户选择
import { mockWx } from "@mptool/mock";
mockWx.user({
modal: "cancel", // 或 "confirm" / { confirm: false, content: "typed" }
actionSheet: 1, // 或 "cancel"
payment: "cancel", // "success" | "cancel" | "fail"
subscribe: "reject", // "accept" | "reject" | "cancel"
userInfo: "deny", // "allow" | "deny"
gesture: false, // 非用户手势(getUserProfile 失败)
});showModal:modal: "cancel"→{confirm:false,cancel:true};{confirm,content}支持editable:true的输入内容;modal未配置时保持默认确认。showActionSheet:按下标返回tapIndex;"cancel"→fail "showActionSheet:fail cancel";越界 → fail。requestPayment/requestSubscribeMessage/getUserProfile/getUserInfo:按payment/subscribe/userInfo/gesture走成功、取消或失败分支。
授权状态
mockWx.auth({ "scope.camera": true, "scope.writePhotosAlbum": false });
mockWx.authorize("deny"); // 所有未授权 scope 的 authorize 都失败wx.getSetting/wx.openSetting返回当前authSetting(只包含配置过或授权过的 scope)。wx.authorize({scope}):已授权 → 成功;scope 明确为false或authorize("deny")→fail "authorize:fail auth deny"并记录false;否则成功并记录true。withScope的已授权 / 首次授权 / 拒绝 /getSetting失败四条分支都可以用上述配置或mockWx.failNext("getSetting", ...)覆盖。saveImageToPhotosAlbum/saveVideoToPhotosAlbum在scope.writePhotosAlbum === false时 failauth deny。
剪贴板
mockWx.clipboard("preset"); // 预置初值
await wx.setClipboardData({ data: "copied" });
(await wx.getClipboardData({})).data; // "copied"UI 状态查询
mockWx.ui();
// {
// toast: { title, icon?, duration? } | null,
// loading: { title } | null,
// modal: { confirm, cancel, content? } | null,
// actionSheet: { tapIndex } | null,
// update: { checked, hasUpdate, ready, failed, applied },
// }showToast / showLoading / showModal / showActionSheet 会更新状态,hideToast / hideLoading 清空对应状态;scope() / reset() 会一起还原。
更新管理器
const manager = wx.getUpdateManager();
manager.onCheckForUpdate(({ hasUpdate }) => {});
manager.onUpdateReady(() => {});
manager.onUpdateFailed(({ errMsg }) => {});
mockWx.emit("updateCheck", { hasUpdate: true });
mockWx.emit("updateReady");
mockWx.emit("updateFailed", { errMsg: "network" });
manager.applyUpdate();
mockWx.ui().update.applied; // true
ui()是新增的只读查询接口;写状态仍然通过mockWx.user/auth/authorize/clipboard与wxAPI 本身完成。
路由 / 页面栈 / 页面生命周期
wx.navigateTo / redirectTo / switchTab / reLaunch / navigateBack 现在维护真实页面栈,并按真机顺序触发页面生命周期。
页面注册
Page({
route: "/pages/detail", // mock 扩展:用于把导航 URL 映射到页面(也支持 $name 末段匹配)
data: { count: 0 },
onLoad(query) {},
onShow() {},
onReady() {},
onHide() {},
onUnload() {},
});route是 mock 的扩展字段(真机Page选项没有它);不写route时按$name与 URL 末段匹配(例如$name: "main"匹配/pages/main)。- 注册后的页面只有在导航时才会创建实例;实例包含
route、options(query)、data(深拷贝注册时的data)与setData(patch, callback?)。 getCurrentPages()返回页面实例数组的快照(修改返回值不影响栈)。
tabBar 页面配置
mockWx.tabBar(["/pages/index/index", "/pages/mine/index"]);- 配置后这些路由被视为 tabBar 页面;也可以用
Page({ route, tabBar: true })/isTabBar: true标记。 switchTab会保留其它 tab 页实例:离开时只触发onHide,切回来只触发onShow,不会重新onLoad、也不会onUnload。getCurrentPages()在 tab 页场景下只包含当前 tab 页;reLaunch会销毁所有 tab 页(onUnload)并清空缓存。- 未配置任何 tab 页时,
switchTab为兼容旧行为不校验目标。
导航与页面栈
| API | 语义 | 失败 |
|---|---|---|
navigateTo({url}) | 压栈,旧页 onHide → 新页 onLoad(query) / onShow / onReady | 页面未注册、栈已满 10 层、目标是 tab 页 |
redirectTo({url}) | 替换栈顶,旧页 onUnload → 新页 onLoad/onShow/onReady | 页面未注册、目标是 tab 页 |
navigateBack({delta?}) | 出栈 delta(默认 1),被出栈页 onUnload,新栈顶 onShow | 已在首页(stack.length <= delta)、非法 delta |
switchTab({url}) | 隐藏当前 tab 页(不销毁)→ 清空非 tab 页 → 目标 tab 页首次创建时 onLoad/onShow/onReady,已存在仅 onShow | 页面未注册、目标非 tabBar 页(存在 tab 页定义时) |
reLaunch({url}) | 全部 onUnload 后清栈(含缓存的 tab 页)、打开目标页 | 页面未注册 |
- 失败通过
fail+complete回调或在无回调时 rejectError(含errMsg)返回;可用mockWx.failNext("navigateTo", ...)额外注入失败。 - 未注册页面的错误:
navigateTo:fail page "/pages/x" is not found;栈满:navigateTo:fail webview count limit exceed;非 tab 页:switchTab:fail can not switch to no-tabBar page。
生命周期
- 新页面:
onLoad(query)→onShow()→onReady(),同步按序调用。 navigateTo前旧页onHide;redirectTo/navigateBack出栈页onUnload;navigateBack后新栈顶onShow。Page注册、App注册、getApp()、Component/Behavior记录仍然保留;getApp()返回最近一次App(options)的选项对象。
生命周期是 mock 简化实现:只覆盖导航引起的页面生命周期,不模拟切后台 / 回前台的
onHide/onShow、渲染层、页面动画与真实onReady时机,也不实现 enhance 的导航锁 /onNavigate钩子;需要这些行为时请继续替换对应wx方法或使用 enhance 层测试。
FileSystemManager 语义
wx.getFileSystemManager() 返回完整的 45 个方法(20 个同步 + 25 个异步),路径解析、encoding、错误码与真机对齐:
- 路径:相对路径按
wx.env.USER_DATA_PATH解析;接受wxfile:///http://前缀(usr用户目录、tmp临时目录)。./..会被解析,越出用户目录(如../../etc/passwd、wxfile://usr/..)会以1300014拒绝。 - encoding:
- 不传 encoding →
ArrayBuffer; - 传 encoding(
utf-8/utf8/ascii/base64/binary/hex/latin1/ucs2/utf16le)→ string(binary是 string,不是 ArrayBuffer); - 未知 encoding → 失败(
Unknown encoding: xxx)。 - 写
ArrayBuffer/TypedArray 时按原始字节保存,不做 UTF-8 解码。
- 不传 encoding →
- 错误:同步方法抛
Error(带errMsg/errCode/errno),异步方法走fail+complete;errMsg形如<api>:fail ...,错误码使用官方表(1300002不存在、1300020not a directory、1300021Is a directory、1300066目录非空、1301003对目录非法操作、1301005已存在、1300014路径越权、1300202超出 100 MB)。 stat:返回{ size, mode, lastAccessedTime, lastModifiedTime, isFile(), isDirectory() };statSync(path, true)对目录返回[{ path, stats }]。- 父目录:写文件、复制、重命名、保存文件都要求父目录已存在(否则
1300002);mkdirSync(path)非递归时父目录必须存在,recursive才会自动创建。 - 文件描述符:
openSync({ filePath, flag })返回 fd;readSync/writeSync/fstatSync/ftruncateSync/closeSync均接收 option 对象(与真机签名一致)。 saveFileSync:源临时文件不存在会失败(1301112);目标已存在失败(1301005)。- 异步方法:回调风格(
success/fail/complete),不支持 Promise 风格(与真机一致);未传回调时调用会返回undefined。 unzip/readZipEntry:mock 不解析真实 zip 二进制;先用文件 API 把「条目表」写成 JSON 作为 zip 内容(mock 约定):
wx.getFileSystemManager().writeFileSync(
`${wx.env.USER_DATA_PATH}/archive.zip`,
JSON.stringify({ "a.txt": "content", "nested/b.bin": { base64: "AAEC" } }),
);
await new Promise((resolve, reject) => {
wx.getFileSystemManager().unzip({
zipFilePath: `${wx.env.USER_DATA_PATH}/archive.zip`,
targetPath: `${wx.env.USER_DATA_PATH}/out`,
success: resolve,
fail: reject,
});
}); // out/a.txt、out/nested/b.bin
// 条目路径越界(zip-slip,如 "../escape.txt")会以 1300014 失败,且不会落盘
getFileInfo的digest目前是占位值,不要用它断言真实哈希。
用开发者工具实测确认:不传 encoding 返回
ArrayBuffer、binary返回 string、stat字段为size/mode/lastAccessedTime/lastModifiedTime;开发者工具里wx.env.USER_DATA_PATH实测为http://usr,错误文案细节与 mock 有差异(mock 以官方错误码 +<api>:fail前缀为准)。
WXML 节点夹具 / SelectorQuery / IntersectionObserver
mock 不渲染 WXML,但可以用节点夹具让 SelectorQuery 和 IntersectionObserver 驱动布局/曝光逻辑。
节点夹具
mockWx.node("#card", {
id: "card",
dataset: { uid: 1 },
left: 10,
top: 20,
width: 300,
height: 120,
scrollLeft: 0,
scrollTop: 0,
node: { tagName: "view" },
context: { kind: "rendering-context" },
ref: { kind: "component" },
fields: { custom: "value" }, // 会合并进 fields() 结果
});
// 同一个 selector 多次注册 => selectAll 返回多个
mockWx.node(".item", { width: 10, height: 10 });
mockWx.node(".item", { width: 20, height: 20 });
// 清空某个 selector
mockWx.node("#card", null);selectViewport()读取mockWx.node("viewport", info)注册的夹具。- 未注册
select→null,未注册selectAll→[];mockWx.scope()/reset()会一并还原夹具。
SelectorQuery
wx.createSelectorQuery()
.in(this)
.select("#card")
.boundingClientRect((rect) => {
console.log(rect); // { id, dataset, left, top, right, bottom, width, height }
})
.selectAll(".item")
.fields(
{
id: true,
dataset: true,
rect: true,
size: true,
scrollOffset: true,
node: true,
context: true,
ref: true,
},
(items) => {},
)
.selectViewport()
.scrollOffset((offset) => {})
.exec((results) => {
// 节点回调先触发,exec 回调最后触发,全部异步(setTimeout 0)
});- 支持
select/selectAll/selectViewport/in,以及boundingClientRect/scrollOffset/fields/node/context/ref。 fields支持id/dataset/rect/size/scrollOffset/node/context/ref/properties;夹具里的fields对象会覆盖/补充结果。
IntersectionObserver
const observer = wx.createIntersectionObserver();
observer.relativeToViewport().observe("#card", (result) => {
// { intersectionRatio, intersectionRect, boundingClientRect, relativeRect, time }
});
mockWx.intersection("#card", {
intersectionRatio: 0.5,
intersectionRect: { left: 0, top: 0, right: 100, bottom: 50, width: 100, height: 50 },
});
observer.takeRecords(); // 触发过且未取走的记录
observer.disconnect(); // 之后不再回调mockWx.intersection(selector, result?)只触发监听同一 selector 的 observer;回调异步(setTimeout 0)。relativeTo/relativeToViewport仅记录,不参与几何计算。
夹具按 selector 精确匹配,不解析 class / 后代选择器,也不模拟布局引擎;
in(component)仍共享全局夹具注册表。
系统信息 / 网络状态 / env
wx.getSystemInfo(Sync) / getWindowInfo / getDeviceInfo / getAppBaseInfo / getSystemSetting / getAppAuthorizeSetting 现在由同一份 mockWx.system() 配置生成,字段与量纲一致。
mockWx.system({
platform: "ios", // ios / android / devtools / windows / mac / ohos
system: "iOS 17.0",
brand: "Apple",
model: "iPhone 15",
SDKVersion: "3.9.0",
version: "8.0.20",
language: "zh_CN",
theme: "dark", // light / dark
windowWidth: 414,
windowHeight: 896,
pixelRatio: 3,
statusBarHeight: 44,
safeArea: { top: 44, bottom: 896, left: 0, right: 414, width: 414, height: 852 },
fontSizeSetting: 20,
enableDebug: true,
bluetoothEnabled: true,
locationEnabled: true,
wifiEnabled: true,
deviceOrientation: "portrait",
userDataPath: "wxfile://usr", // 同时更新 wx.env.USER_DATA_PATH 与文件系统根目录
});- 默认
platform = "devtools"、brand = "devtools"、model = "test"、system = "macOS 14.0"、SDKVersion = "3.8.0"、version = "8.0.0"、窗口 375×667、pixelRatio = 2、statusBarHeight = 20。 safeArea未配置时按statusBarHeight/ 窗口尺寸推导;getWindowInfo().screenTop为 0。getSystemInfo返回 32 个官方字段 +theme,getSystemInfoSync与异步版本一致(异步多errMsg)。- 默认
wx.env.USER_DATA_PATH = "wxfile://usr"(真机取值;开发者工具可能显示http://usr,文件 API 两种 scheme 都接受),wx.version = "8.0.30"。 mockWx.auth({ scope: false })会同时反映到getAppAuthorizeSetting()与getSystemInfo().*Authorized(未配置的 scope 默认 authorized,保持旧行为)。
网络状态
mockWx.network({
networkType: "4g", // wifi / 2g / 3g / 4g / 5g / none / unknown
signalStrength: 42, // iOS 0~1,其他平台 0~100;未配置时按 platform 推导
weakNet: true,
offline: true, // 等价于 networkType: "none"
});
wx.onNetworkStatusChange(({ isConnected, networkType }) => {});
mockWx.network({ networkType: "5g" }); // 变化时派发事件getNetworkType返回networkType/signalStrength/weakNet;getConnectedWifi的signalStrength使用同一配置。- 默认平台为
devtools,默认signalStrength = 100(iOS 平台默认 1,与 report 等消费方的量纲判断一致)。 - 只有网络类型/连接状态实际变化时
onNetworkStatusChange才派发;离线用offline: true或networkType: "none"。 mockWx.scope()/reset()会还原 system / network /USER_DATA_PATH。
通用 API(A2 批次 1)
除系统信息外,以下通用 API 也已实现:
- 基础工具:
base64ToArrayBuffer/arrayBufferToBase64/getRandomValues。 - 系统/配置:
getSystemInfoAsync(与getSystemInfoSync同源)、getSkylineInfo(Sync)、getApiCategory、getDeviceBenchmarkInfo、getRendererUserAgent、getCommonConfig、getExtConfig(Sync)。 - 性能:
getPerformance(getEntries*/now/timeOrigin/setBufferSize/createObserver桩)、reportPerformance、reportMonitor、requestIdleCallback/cancelIdleCallback(异步回调,可用cancelIdleCallback取消)。 - 页面:
enableAlertBeforeUnload/disableAlertBeforeUnload、showNavigationBarLoading/hideNavigationBarLoading、startPullDownRefresh、setTopBarText、rewriteRoute。
const base64 = wx.arrayBufferToBase64(new Uint8Array([0, 1, 2]).buffer);
const bytes = wx.base64ToArrayBuffer(base64);
await wx.getSystemInfoAsync({});
await wx.getRendererUserAgent(); // "Mozilla/5.0 ... MicroMessenger/8.0.30"
mockWx.system({
apiCategory: "native",
benchmark: { benchmarkLevel: 30, modelLevel: 1 },
extConfig: { channel: "test" },
rendererUserAgent: "custom-agent",
skyline: { isSupported: true, version: "0.9.7" },
});
wx.requestIdleCallback(({ timeRemaining }) => {
timeRemaining();
});这些 API 的失败分支同样支持
mockWx.failNext(...)注入;canIUse对它们返回true。媒体 / 分享 / 音频类 API 见下一个 PR。
媒体 / 分享 / 音频(A2 批次 2)
- 媒体:
chooseAddress/chooseContact/chooseInvoice/chooseInvoiceTitle/choosePoi(返回内置的 mock 选择结果)、previewMedia、compressVideo/cropImage/editImage/openVideoEditor(返回mock://tmp/...临时路径)、saveFileToDisk、openPrivacyContract。 - 分享 / 小程序生命周期:
shareAppMessageToGroup、shareEmojiToGroup、shareFileMessage、shareFileToGroup、shareImageToGroup、shareToOfficialAccount、shareToWeRun、shareVideoMessage、shareVideoToGroup、restartMiniProgram、updateWeChatApp、exitMiniProgram、postMessageToReferrerPage、postMessageToReferrerMiniProgram。 - 音频:
playVoice/pauseVoice/stopVoice、playBackgroundAudio/pauseBackgroundAudio/stopBackgroundAudio/seekBackgroundAudio、getBackgroundAudioPlayerState、setInnerAudioOption、getAvailableAudioSources、getAudioFileInfo、startRecord/stopRecord。
await wx.chooseAddress({});
await wx.compressVideo({ src: "a.mp4" }); // { tempFilePath: "mock://tmp/compressed.mp4" }
await wx.playVoice({ filePath: "a.mp3" });
await wx.getBackgroundAudioPlayerState({}); // { status: 0, downloadPercent: 100, ... }
mockWx.failNext("chooseAddress", { type: "cancel" });- 媒体选择类返回固定的示例数据(地址、联系人、发票、POI);压缩/裁剪/编辑只返回 mock 临时路径,不处理真实文件。
- 音频类只保证回调/字段形状,不播放真实音频;
startRecord立即成功、stopRecord返回 mock 临时路径。 - 失败/取消分支同样支持
mockWx.failNext,canIUse对它们返回true。
canIUse / nextTick
wx.canIUse("request"); // true
wx.canIUse("totallyNotAnApi.method"); // false
wx.canIUse("openBluetoothAdapter"); // false(非目标 API)
wx.nextTick(() => {}); // 下一时间片执行(不再同步)A3 管理器桩 / A4 非目标 / 覆盖守护
A3 管理器桩(能创建、方法齐全、事件可注册)
已补齐以下工厂 / 管理器:createAnimation、createCameraContext、createMediaRecorder、createWebAudioContext、createAudioContext、createVideoDecoder、createWorker、createOffscreenCanvas、createTCPSocket / createUDPSocket、createBufferURL / revokeBufferURL、createCacheManager、getPluginUpdateManager、getUserCryptoManager、loadFontFace / loadBuiltInFontFace、preDownloadSubpackage / preloadAssets / preloadWebview / preloadSkylineView、openEmbeddedMiniProgram。
const animation = wx.createAnimation({ duration: 100 });
animation.translate(10, 20).opacity(0).step().export();
const recorder = wx.createMediaRecorder();
recorder.onStart(() => {});
mockWx.emit("mediaRecorderStart");
const worker = wx.createWorker("worker.js");
worker.onMessage((message) => {});
mockWx.emit("workerMessage", { message: "hi" });
const cache = wx.createCacheManager();
cache.set("key", "value");
cache.get("key"); // "value"- 管理器事件(
cameraFrame、mediaRecorder*、workerMessage、socketMessage、pluginUpdate*)通过mockWx.emit(<事件名>, ...)触发,与通用事件总线一致。 - 只保证「可创建、方法存在、回调/事件形状正确」,不实现真实渲染、音视频、Worker、Socket 协议或缓存持久化。
A4 非目标(明确不模拟)
以下 API 属于硬件 / 生态能力,mock 不实现,canIUse 返回 false,也不伪造成功:
- 蓝牙 / BLE、Beacon、NFC / HCE / 交通卡、Secure Element / 支付卡;
- 视频号 / 直播 / 广告、公众号 / 表情 / 贴纸、聊天工具;
- 支付 / 卡券 / 门店 / 离线支付、Employee / 设备 VoIP、SOTER / 人脸 / VK / XR 等。
完整名单见 packages/mock/src/unsupported.ts 的 A4_UNSUPPORTED_APIS(97 个)与 DEFERRED_APIS(24 个,渠道 / 聊天工具 / 日历等长尾);两者合并为 UNSUPPORTED_APIS(121 个)。另有 MOCK_ONLY_APIS(15 个)列出 mock 暴露但 typings 没有(或已移除)的 API,例如 getFileInfo、saveFile、getSavedFileList、showKeyboard、offSocket*。
覆盖守护
packages/mock/__tests__/coverage.spec.ts 会解析 miniprogram-api-typings 的 interface Wx:
- 断言「typings 方法 −
Object.keys(wx)」恰好等于UNSUPPORTED_APIS; - 断言「
Object.keys(wx)− typings 方法」恰好等于MOCK_ONLY_APIS; - 断言所有
UNSUPPORTED_APIS的canIUse返回false,已实现 API 返回true。
typings 升级导致方法增减时,这个测试会变红,从而把「补 API / 更新 allowlist」变成显式动作。
事件监听与手动触发
wx 的事件监听 API(如 onThemeChange、onAppShow、onWindowResize 等)默认不会自动触发,你需要通过导出的 emitEvent 手动触发,以模拟真实场景:
import { emitEvent, wx } from "@mptool/mock";
const listener = vi.fn();
wx.onThemeChange(listener);
emitEvent("themeChange", { theme: "dark" });
expect(listener).toHaveBeenCalledWith({ theme: "dark" });支持手动触发的事件一览:
| 监听 API | 事件名 | 参数示例 |
|---|---|---|
onThemeChange | themeChange | { theme: "dark" } |
onAppShow | appShow | { path, query, scene } |
onAppHide | appHide | - |
onWindowResize | windowResize | { size: { windowWidth, windowHeight } } |
onUserCaptureScreen | userCaptureScreen | - |
onKeyboardHeightChange | keyboardHeightChange | { height } |
onCopyUrl | copyUrl | { query } |
onNeedPrivacyAuthorization | needPrivacyAuthorization | { needAuthorization, privacyContractName } |
onAccelerometerChange | accelerometerChange | { x, y, z } |
onCompassChange | compassChange | { direction } |
onDeviceMotionChange | deviceMotionChange | { alpha, beta, gamma } |
onNetworkStatusChange | networkStatusChange | { isConnected, networkType } |
onSocketOpen | socketOpen | { header } |
onSocketMessage | socketMessage | { data } |
onSocketError | socketError | { errMsg } |
onSocketClose | socketClose | { code, reason } |
off* API 传入监听函数可移除对应监听器,不传参数则移除该事件的全部监听器。
全量事件 API(A1)
除上表外,miniprogram-api-typings 里 interface Wx 声明的所有事件对都已补齐(共 71 个 onXxx、其中 62 个有对应 offXxx),包括 onError、onUnhandledRejection、onPageNotFound、onMemoryWarning、onAppRoute、onBeforePageLoad/onAfterPageLoad、onBLEConnectionStateChange、onBeaconUpdate、onVoIPChatStateChanged、onWifiConnected、onKeyDown/onKeyUp、onScreenRecordingStateChanged 等。
- 事件名 = 去掉
on后的后缀,首字母小写;连续大写缩写按常见读法转小写(BLE→ble、VoIP→voip、HCE→hce、NFC→nfc、SOTER→soter、WiFi→wifi)。 - 用
mockWx.emit(<事件名>, ...参数)触发;offXxx()不传监听器会清空该事件的全部监听器。 - 没有对应
offXxx的事件(如onError)只能注册,测试间可用mockWx.reset()或scope()隔离。
事件触发时机
以下事件已接到 mock 的真实行为上,不需要手动 emit:
| 事件 | 触发时机 | payload |
|---|---|---|
onPageNotFound | navigateTo / redirectTo / switchTab / reLaunch 目标页面未注册时(导航失败回调仍然保留) | { path, query, isEntryPage: false }(path 带前导 /) |
onBeforeAppRoute | 成功导航、页面生命周期之前 | { path, query, openType } |
onAppRoute | 成功导航、目标页面打开(onLoad/onShow/onReady)之后 | { path, query, openType } |
onAppRouteDone | 成功导航、success/complete 回调之后 | { path, query, openType } |
onNetworkStatusChange | mockWx.network() 改变了网络类型 / 连接状态 | { isConnected, networkType } |
onWifiConnected | mockWx.network() 切到 wifi 时 | { wifi }(与 getConnectedWifi 一致) |
onNetworkWeakChange | mockWx.network({ weakNet }) 改变时 | { networkType, weakNet } |
onGetWifiList | 调用 wx.getWifiList() 后 | { wifiList }(mockWx.network({ wifiList })) |
onUnhandledRejection | mockWx.unhandledRejection(reason, promise?) | { reason, promise } |
onMemoryWarning | mockWx.memoryWarning(level?) | { level }(默认 10) |
onError | mockWx.error(message) | message(string) |
openType 取值为 navigateTo / redirectTo / switchTab / reLaunch / navigateBack。
const calls: string[] = [];
wx.onBeforeAppRoute(({ openType }) => calls.push(`before:${openType}`));
wx.onAppRoute(({ openType }) => calls.push(`route:${openType}`));
wx.onAppRouteDone(({ openType }) => calls.push(`done:${openType}`));
await wx.navigateTo({ url: "/pages/detail?id=1" });
// calls: before:navigateTo → route:navigateTo → done:navigateTo
mockWx.unhandledRejection("boom", Promise.resolve());
mockWx.memoryWarning(15);
mockWx.error("script error");其余事件(BLE / Beacon / VoIP / 传感器 / 键盘 / 录屏等硬件或平台事件)仍通过
mockWx.emit(<事件名>, ...)手动触发;mock 不伪造没有真实来源的硬件行为。
wx.onError((error) => {
console.log(error);
});
mockWx.emit("error", "boom");
wx.onBLEConnectionStateChange(listener);
mockWx.emit("bleConnectionStateChange", { deviceId: "d1", connected: true });管理器对象
wx.createInnerAudioContext()、wx.createVideoContext()、wx.createCanvasContext()、wx.getRecorderManager() 等常用管理器返回可调用的对象;A3 的 createAnimation / createCameraContext / createMediaRecorder / createWorker / socket / cache manager 等也已提供桩实现(能创建、方法齐全、事件可注册),见 A3 管理器桩。
const audio = wx.createInnerAudioContext();
audio.src = "https://example.com/a.mp3";
audio.play();
audio.pause();存储与文件系统
wx存储 API(getStorage/setStorage/getStorageSync等)基于内存实现,测试间可通过wx.clearStorageSync()清理。wx.getFileSystemManager()返回基于内存的文件系统管理器,支持readFileSync/writeFileSync/mkdirSync/statSync等操作。
支持范围
| 类别 | 状态 | 说明 |
|---|---|---|
wx 常用 API | 已支持 | storage、FSM、路由/页面栈、UI/权限/剪贴板、网络/系统信息、更新、事件、A2 通用 API、A3 管理器桩 |
| 页面生命周期 | 部分模拟 | onLoad / onShow / onReady / onHide / onUnload 按导航顺序同步触发;不模拟渲染、动画与切后台生命周期 |
| 网络 / 系统信息 | 已支持 | 可配置类型、信号强度、弱网、系统字段与授权状态 |
| WXML / SelectorQuery | 部分模拟 | 节点夹具 + 异步 exec;无真实布局引擎与选择器解析 |
| 压缩 / 解压 / 摘要 | 部分模拟 | unzip 使用 JSON 条目表;getFileInfo().digest 为占位值;readCompressedFile 不真实解压 |
| 硬件 / 生态 API | 非目标 | BLE、Beacon、NFC/HCE/交通卡、支付、视频号/直播/广告、VoIP、SOTER/人脸/VK/XR 等,见 A4_UNSUPPORTED_APIS |
| 渠道 / 聊天 / 日历长尾 | 非目标 | 见 DEFERRED_APIS;两者合并为 UNSUPPORTED_APIS,canIUse 返回 false |
非目标清单维护在 packages/mock/src/unsupported.ts,并由 packages/mock/__tests__/coverage.spec.ts 与 typings 做严格比对。
与开发者工具 / 真机的差异和已知限制
USER_DATA_PATH:mock 使用真机值wxfile://usr;开发者工具可能显示http://usr。文件系统同时接受wxfile:///http://两种 scheme。- 文件系统:异步失败结果额外暴露
errCode/errno(开发者工具通常只有errMsg);错误文案按官方错误表,不保证与工具逐字一致。 unzip/readZipEntry:不解析真实 zip 二进制,使用 JSON 条目表({"a.txt":"a"}或{"a.bin":{"base64":"..."}});zip-slip 会被拒绝。getFileInfo().digest:确定性占位哈希,不能当真实 md5/sha1/sha256 使用。readCompressedFile:忽略压缩算法,直接返回文件字节(无 Brotli 解压)。- 事件:事件总线同步派发(真机异步);
onPageNotFound/ 路由事件 / 网络事件等已接真实行为,硬件类事件仍需mockWx.emit。 - 导航:
Page({ route })与tabBar标记是 mock 扩展;无route时按$name与 URL 末段匹配。生命周期同步执行,无渲染时序与 enhance 导航锁。 canIUse:按 API 名判定(实现为 true、非目标为 false),不是基础库版本能力矩阵。- 管理器:只保证可创建、方法存在、回调/事件形状;不执行真实渲染、音视频、Worker、Socket 协议或 canvas 绘制。
- 网络:不启动真实 HTTP 服务;
request/downloadFile/uploadFile通过mockWx.route/respond/fail构造响应,Task 形态与真机一致。 - 系统信息:默认
platform = devtools、brand = devtools、system = macOS 14.0;可通过mockWx.system()覆盖。 - mock-only:
getFileInfo、saveFile、getSavedFileList、showKeyboard、offSocket*等历史 API/属性仍在 mock 中,但当前 typings 可能未声明,见MOCK_ONLY_APIS。 - 进程副作用:
mockWx不监听宿主process,unhandledRejection/memoryWarning/error通过显式 helper 触发。
从旧 mock 迁移
如果旧测试使用手写 wx mock 或 vi.stubGlobal("wx", {...}),可以按下面方式迁移;直接替换单个 wx.xxx 仍然可用且优先级最高,但优先用 mockWx 能保留真实状态与失败语义。
| 旧写法 | 新写法 |
|---|---|
vi.stubGlobal("wx", { getStorage: ... }) | import "@mptool/mock" + wx.setStorageSync(...) / mockWx.storage(...) |
wx.showModal = vi.fn(({ success }) => success({ confirm: false })) | mockWx.user({ modal: "cancel" }) |
wx.getSetting = vi.fn(({ success }) => success({ authSetting: {} })) | mockWx.auth({}) / mockWx.authorize("deny") |
wx.authorize = vi.fn(({ fail }) => fail()) | mockWx.authorize("deny") 或 mockWx.failNext("authorize", { type: "permission" }) |
wx.getNetworkType = vi.fn(...) | mockWx.network({ networkType: "4g" }) |
wx.getSystemInfoSync = vi.fn(...) | mockWx.system({ platform: "ios", ... }) |
| 手写请求返回 / 失败 | mockWx.route() / mockWx.respond() / mockWx.fail() |
手动 emitEvent("networkStatusChange") | mockWx.network(...) 自动派发 |
手动构造页面栈 / 替换 wx.navigateTo | Page({ route }) + 真实 wx.navigateTo / navigateBack |
手动替换 wx.createSelectorQuery | mockWx.node() + 真实 SelectorQuery |
// before
const original = wx.showModal;
wx.showModal = ({ success }) => success({ confirm: false });
// afterEach: wx.showModal = original
// after
import "@mptool/mock";
import { mockWx } from "@mptool/mock";
mockWx.user({ modal: "cancel" });不要用
vi.stubGlobal("wx", ...)替换整个对象:模块内已经捕获了 mock 的wx引用,替换全局对象会让两者分叉。需要精确结果时优先用mockWx,必要时只替换单个方法。