
1. 项目概述为什么要在Cocos Creator微信小游戏里搞UI自动化最近在跟一个用Cocos Creator 3.6.3开发的微信小游戏项目团队规模稍微大了一点每次发版本前的手动回归测试就成了老大难。美术改个按钮位置、策划调个弹窗流程测试同学就得把整个核心流程再跑一遍费时费力还容易漏。这时候UI自动化测试的需求就冒出来了。说白了我们就是想写一些脚本让机器自动去点点游戏里的按钮、检查弹窗、验证数值把那些重复、固定的测试流程给固化下来解放人力提高版本交付的信心。对于Cocos Creator尤其是3.x版本UI自动化有它的特殊性。它既不是传统的Web前端虽然有Canvas也不是纯粹的原生应用。微信小游戏环境更是一个“套娃”Cocos游戏跑在微信小游戏的Runtime里而微信小游戏本身又是一个特殊的浏览器环境。这意味着你没法直接用Selenium去操作DOM因为Cocos渲染的是Canvas或WebGL。核心思路得变要么通过游戏引擎暴露的接口来“驱动”UI要么通过图像识别来“看到”并点击UI。前者更精准稳定但需要开发配合后者通用性强但对环境变化敏感。2. 核心思路与方案选型引擎驱动 vs. 图像识别面对这个混合环境我们主要有两条技术路线可以走各有优劣需要根据项目实际情况来权衡。2.1 方案一基于引擎接口的“白盒”驱动这个方案的核心思想是利用Cocos Creator引擎本身提供的机制从内部获取UI节点并模拟交互。这通常需要开发同学在项目中注入一些“钩子”代码。实现原理在游戏代码中暴露一个全局对象例如window.$TestDriver这个对象提供一系列方法如findNode(‘Btn_Start’)、clickNode(node)、getNodeText(‘ScoreLabel’)等。这些方法内部直接调用Cocos的find、emit等API来定位和操作节点。然后外部的自动化脚本比如用Node.js或Python写的通过微信小游戏的调试通道如微信开发者工具的自动化接口或者WebSocket与这个全局对象通信发送指令并获取结果。优势精准高效直接通过节点名或路径查找无视UI位置、颜色变化只要节点结构不变脚本就稳定。可验证内部状态不仅能点击还能直接读取组件上的属性值比如玩家的金币数、某个开关的状态这是图像识别做不到的。执行速度快没有图像处理开销。劣势侵入性强需要修改游戏源码增加测试专用的代码可能对线上包有轻微影响需通过编译宏控制。依赖开发测试同学需要了解Cocos的节点树结构和组件系统或者由开发提供稳定的查询接口。环境依赖通常需要依赖微信开发者工具的自动化接口对持续集成CI环境部署有一定要求。2.2 方案二基于图像识别的“黑盒”驱动这个方案把游戏界面纯粹当作一幅图像来处理。自动化脚本通过截屏然后使用图像匹配算法如OpenCV的模板匹配或者更先进的OCR光学字符识别技术来定位UI元素的位置并模拟点击。实现原理在测试环境中可以是真机也可以是带屏录的模拟器脚本控制鼠标/触摸坐标在屏幕上点击。首先它会截取当前游戏画面然后用预先准备好的UI元素截图比如“开始按钮.png”在全屏图中进行匹配找到最相似的位置最后计算该位置的屏幕坐标并注入一个触摸事件。优势无侵入完全不需要改动游戏代码对游戏进程零影响。通用性强理论上适用于任何游戏引擎甚至任何应用技术栈无关。更贴近真实用户模拟的是真实点击操作能覆盖到一些底层驱动可能忽略的渲染或交互问题。劣势稳定性挑战大UI位置变动、美术资源更新、设备分辨率差异、抗锯齿效果、动态特效如按钮发光都会导致匹配失败。需要精心准备模板图片并设置合理的匹配阈值。无法获取内部数据只能“看到”界面无法直接读取内存中的分数、状态等。执行效率低截屏和图像匹配比较耗时测试用例运行慢。环境搭建复杂需要处理设备连接、屏幕截图、坐标转换等问题。2.3 我们的混合策略选择在实际项目中我们很少会走极端。我推荐采用一种以引擎驱动为主图像识别为辅的混合策略。核心业务流程使用方案一引擎驱动。比如登录、战斗、领取奖励等主线流程这些UI元素相对稳定且需要验证内部数据如战斗后金币是否增加。这保证了核心自动化用例的稳定性和可靠性。非关键或动态UI使用方案二图像识别。比如偶尔弹出的运营活动公告、位置可能变化的浮动图标或者那些不太方便通过节点访问的纯美术元素。作为对主流程的补充。兜底与验证即使使用引擎驱动点击了按钮我们也可以用图像识别截一张图作为测试通过的“证据”存档或者用OCR简单验证一下结果页面上有没有出现预期的文字如“胜利”。对于Cocos Creator 3.6.3 微信小游戏这个特定组合我建议优先攻克基于引擎接口的方案因为它是性能、稳定性和可维护性的最佳平衡点。下面我就重点拆解这个方案的落地细节。3. 搭建基于引擎接口的UI自动化测试框架这套框架可以理解为由三部分组成游戏内的测试代理Agent、中控测试脚本Runner和通信桥梁Channel。3.1 第一步在Cocos项目中植入测试代理这是整个方案的基础。我们需要在游戏中创建一个不干扰正常逻辑的测试模块。1. 创建测试代理模块在项目的assets/scripts下新建一个目录比如test-automation。创建一个TestAgent.ts文件。// TestAgent.ts import { _decorator, Component, Node, find, director } from cc; // 假设我们使用WebSocket进行通信需要引入相应的库需自行安装或使用平台支持的 // import { WebSocket } from websocket; // 示例微信小游戏环境需用其自有API export class TestAgent extends Component { private static _instance: TestAgent null; private ws: any null; // WebSocket 连接实例 public static get instance(): TestAgent { if (!this._instance) { const node new Node(TestAgent); director.getScene().addChild(node); this._instance node.addComponent(TestAgent); DontDestroyOnLoad(node); } return this._instance; } start() { this.initCommunication(); } // 初始化通信连接这里以WebSocket为例实际可能是其他RPC方式 private initCommunication() { // 注意微信小游戏环境中不能随意创建WebSocket连接到任意地址 // 通常需要在中控端Node.js脚本启动一个服务端游戏作为客户端连接。 // 这里仅展示思路具体连接逻辑需适配微信环境。 const wsUrl ws://localhost:8080; // 中控脚本启动的服务地址 try { this.ws new WebSocket(wsUrl); this.ws.onmessage (event) { this.handleCommand(event.data); }; this.ws.onopen () { console.log([TestAgent] Connected to test runner.); }; } catch (e) { console.warn([TestAgent] WebSocket not available or failed:, e); } } private handleCommand(cmdStr: string) { try { const cmd JSON.parse(cmdStr); const { method, params, id } cmd; let result; let error null; switch (method) { case findNode: result this._findNode(params.path); break; case clickNode: result this._clickNode(params.path); break; case getComponentProperty: result this._getComponentProperty(params.path, params.component, params.property); break; // ... 其他命令 default: error Unknown method: ${method}; } const response { id, result, error }; this.ws.send(JSON.stringify(response)); } catch (e) { console.error([TestAgent] Handle command error:, e); } } // --- 暴露给自动化脚本的核心API --- private _findNode(path: string): { uuid?: string, pos?: { x, y } } | null { const node find(path); if (node) { // 将节点世界坐标转换为视口坐标简化处理可能需要相机信息 const worldPos node.worldPosition; // 这里需要一个将世界坐标转到屏幕坐标的逻辑依赖于你的UI相机设置 // const screenPos this.uiCamera.worldToScreen(worldPos); return { uuid: node.uuid, // pos: { x: screenPos.x, y: screenPos.y } // 可选返回坐标供外部参考 }; } return null; } private _clickNode(path: string): boolean { const node find(path); if (node) { // 模拟点击发送一个触摸事件到该节点 const eventTouch new Event.EventTouch([new Event.Touch(node)], false); node.dispatchEvent(eventTouch); // 更常见的做法是直接调用按钮组件的方法 const button node.getComponent(Button); if (button) { button.clickEvents.emit([]); return true; } // 或者触发自定义事件 node.emit(click); return true; } return false; } private _getComponentProperty(path: string, compName: string, propName: string): any { const node find(path); if (node) { const comp node.getComponent(compName); if (comp comp[propName] ! undefined) { return comp[propName]; } } return null; } } // 为了方便外部调用挂载到全局对象注意微信小游戏是全局的GameGlobal declare global { interface Window { $TestAgent?: TestAgent; } } if (typeof window ! undefined) { window.$TestAgent TestAgent.instance; }2. 条件编译与集成我们肯定不希望测试代码被打进正式发布包。可以利用Cocos Creator的自定义宏。在项目设置里定义一个宏比如AUTOMATION_TEST。在TestAgent.ts的启动逻辑和挂载全局对象的代码处用#if AUTOMATION_TEST包裹。在构建微信小游戏时只有需要自动化测试的版本才勾选或传入这个宏定义。3. 节点查找策略find(‘path’)是最直接的但依赖于节点路径的稳定性。更好的做法是给重要的UI节点添加唯一的自定义属性比如>mkdir game-ui-automation cd game-ui-automation npm init -y npm install typescript ts-node types/node ws chalk axios --save-dev2. 创建通信客户端与服务端我们需要一个稳定的通信层。这里简化处理在测试脚本中启动一个WebSocket服务器等待游戏连接。// src/comm/channel.ts import WebSocket, { WebSocketServer } from ws; import { EventEmitter } from events; export class AutomationChannel extends EventEmitter { private wss: WebSocketServer; private clientSocket: WebSocket | null null; private commandQueue: Array{cmd: any, resolve: Function, reject: Function} []; private reqId 0; constructor(port: number 8080) { super(); this.wss new WebSocketServer({ port }); console.log([Channel] WebSocket server started on port ${port}); this.wss.on(connection, (ws) { console.log([Channel] Game client connected.); this.clientSocket ws; this.emit(connected); ws.on(message, (data) { this.handleMessage(data.toString()); }); ws.on(close, () { console.log([Channel] Game client disconnected.); this.clientSocket null; this.emit(disconnected); }); }); } private handleMessage(msg: string) { try { const { id, result, error } JSON.parse(msg); const pendingReq this.commandQueue.find(req req.cmd.id id); if (pendingReq) { if (error) { pendingReq.reject(new Error(error)); } else { pendingReq.resolve(result); } // 从队列移除 this.commandQueue this.commandQueue.filter(req req.cmd.id ! id); } } catch (e) { console.error([Channel] Failed to parse message:, e); } } public async sendCommand(method: string, params: any): Promiseany { return new Promise((resolve, reject) { if (!this.clientSocket) { reject(new Error(No game client connected.)); return; } const cmd { jsonrpc: 2.0, method, params, id: this.reqId }; this.commandQueue.push({ cmd, resolve, reject }); this.clientSocket.send(JSON.stringify(cmd)); }); } public async waitForConnection(timeout 30000): Promisevoid { if (this.clientSocket) return; return new Promise((resolve, reject) { const timer setTimeout(() reject(new Error(Wait for connection timeout)), timeout); this.once(connected, () { clearTimeout(timer); resolve(); }); }); } }3. 封装游戏驱动层基于Channel封装一个对测试用例更友好的GameDriver类。// src/core/driver.ts import { AutomationChannel } from ../comm/channel; export class GameDriver { constructor(private channel: AutomationChannel) {} async click(path: string): Promiseboolean { return await this.channel.sendCommand(clickNode, { path }); } async find(path: string): Promiseany { return await this.channel.sendCommand(findNode, { path }); } async getProperty(path: string, component: string, property: string): Promiseany { return await this.channel.sendCommand(getComponentProperty, { path, component, property }); } async waitForElement(path: string, timeout 5000, interval 200): Promiseany { const start Date.now(); while (Date.now() - start timeout) { const node await this.find(path); if (node) return node; await this.sleep(interval); } throw new Error(Element ${path} not found within ${timeout}ms); } async sleep(ms: number): Promisevoid { return new Promise(resolve setTimeout(resolve, ms)); } }3.3 第三步编写并运行你的第一个自动化测试用例现在我们可以用Mocha、Jest等测试框架来组织用例了。这里以简单示例说明。// test/cases/login.test.ts import { GameDriver } from ../src/core/driver; import { AutomationChannel } from ../src/comm/channel; import { expect } from chai; // 假设使用chai断言库 describe(游戏登录流程, function() { this.timeout(60000); // 设置用例超时时间 let driver: GameDriver; let channel: AutomationChannel; before(async () { // 1. 启动通信通道 channel new AutomationChannel(8080); driver new GameDriver(channel); // 2. 等待游戏连接需要先启动微信开发者工具并运行游戏 console.log(等待游戏连接...); await channel.waitForConnection(); console.log(游戏已连接开始测试。); // 3. 可以在这里做一些全局准备比如确保游戏在初始界面 await driver.sleep(3000); // 等待游戏加载 }); after(async () { // 测试结束后清理比如关闭WebSocket服务器 // channel.close(); }); it(应该能成功点击开始按钮进入主界面, async () { // 使用>// pages/HomePage.ts export class HomePage { constructor(private driver: GameDriver) {} get startButton() { return Canvas/UI/HomePanel/Btn_Start; } get goldLabel() { return Canvas/UI/HomePanel/TopBar/GoldLabel; } async clickStart() { await this.driver.click(this.startButton); } async getGoldValue(): Promisenumber { const text await this.driver.getProperty(this.goldLabel, Label, string); return parseInt(text, 10); } } // 在测试用例中使用 it(should work, async () { const homePage new HomePage(driver); await homePage.clickStart(); expect(await homePage.getGoldValue()).to.equal(100); });数据驱动测试将测试输入和预期输出放在外部JSON或CSV文件中让同一个测试逻辑可以运行多组数据。5.5 持续集成CI集成要点环境准备CI机器上需要安装微信开发者工具命令行版本并完成登录授权。构建带测试宏的包在CI脚本中使用Cocos Creator命令行cocos build并传入-m AUTOMATION_TEST来构建专门用于自动化测试的小游戏包。自动启动与连接使用微信开发者工具的CLI命令自动打开项目、启动预览。然后通过自动化接口连接游戏实例。测试报告使用mochawesome或jest-html-reporter等生成美观的HTML测试报告并归档。对于失败的用例自动截取游戏画面和日志便于排查。稳定性CI环境可能不如本地稳定需要增加重试机制、更长的超时时间并做好环境清理关闭残留的微信开发者工具进程。搭建UI自动化测试框架尤其是对于Cocos Creator小游戏这种复杂环境初期投入确实不小。但一旦框架跑通编写新用例的成本会急剧下降。它带来的回报是持续的更快的回归验证速度、更高的版本质量、以及测试同学从重复劳动中解放出来去探索更有价值的测试场景。从一两个核心场景开始逐步扩展你的自动化用例库你会发现这笔投资非常值得。