“测试全绿”这四个字,我盯着CI面板看了十秒钟,心里的石头几乎要落地。《清单之外》这个微信小游戏,从玩法算法到界面交互,开发全程大量使用AI辅助写代码,核心逻辑的单元测试覆盖率做到了85%,所有用例一次通过。我甚至已经开始琢磨版本号应该定1.0.0还是1.1.0了。
然后我把构建产物导进微信开发者工具,第一屏白屏,Console抛出一行冷冰冰的红色报错:ReferenceError: localStorage is not defined。
那一刻我才意识到,测试全绿和能上线,是两个次元的事。这篇复盘就是记录《清单之外》从“测试全绿”到“进Cocos构建后失败”的完整过程:到底哪里崩了、为什么单测没拦住、AI辅助开发在这个过程中埋了什么雷,以及我们最后怎么把坑填平,还顺手建了一套防再犯的检查机制。如果你也在做Cocos小游戏,尤其是用AI帮忙写代码,这篇值得看完。
1. 项目复盘:AI辅助开发《清单之外》,我为什么敢信“全绿”
1.1 玩法与模块划分:清单选择驱动规则演化
《清单之外》的核心玩法不复杂:玩家每天拿到一张自动生成的待办清单,每一件待办可以选择“完成”或“忽略”,而这个选择会直接影响第二天清单里事项出现的概率和排序方式。把话说直白一点,它就是一个带规则引擎的待办游戏,重点不在操作,而在背后那一套会自我调整的生成算法。
为了不让代码搅成一锅粥,我把项目拆成了四个独立模块:
- 清单生成模块:根据权重表、历史选择记录、日期因子,生成当天的待办列表。
- 状态机模块:管理每条待办的“未处理 / 已完成 / 已忽略”流转。
- 数据存储模块:负责本地存档,包括玩家历史选择、生成过的清单、统计信息。
- 统计模块:计算完成率、连续选择偏好等,用于后续调整权重。
这种划分给AI辅助开发提供了很好的上下文窗口,我可以在提示词里点名“只改状态机模块”,AI产出的代码基本不会跑到别的文件里去动全局变量。
1.2 测试方案的源头:Mock出来的“安全网”
既然玩法核心是规则引擎,第一反应就是把规则逻辑全部用单元测试锁死。我用的是Jest,测试策略非常朴素:凡是涉及wx、Cocos引擎接口、文件I/O的地方,一律Mock掉,让测试跑在“纯逻辑环境”里。具体来说,我在测试文件里写了一个简易的wxMock,覆盖了setStorageSync、getStorageSync、getUserInfo这些我们用到的方法,然后用jest.mock把项目里的wx全局替换成这个Mock对象。
测试用例本身写得也勤快:权重边界、空清单处理、连续三天忽略某事项后权重衰减……一共一百多个用例,CI流水线上全绿。站在当时的视角,代码里最复杂的部分都被覆盖了,甚至有些AI生成的函数我都没完全看懂,但只要Mock一到位,测试就是能过。
后来我在复盘笔记里给这段经历起了个名字:Mock出来的安全网。它在逻辑层面确实是安全网,但在环境层面,它是一堵完全透明的空气墙。我当时太依赖于“全绿”这个信号,忽略了绿灯背后其实有一整套“假设”——假设wx存在、假设存储接口返回的是字符串、假设所有数据加载都是同步的、假设任何异步资源在onLoad阶段都已经就位。
2. 进Cocos构建再适配微信小游戏:失败现场全记录
2.1 白屏、报错、数据丢失:一个也没跑掉
Cocos Creator的构建在预览器里一切正常。我用浏览器模拟调试了很多次,UI没问题,交互没问题,核心玩法的体验也没问题。构建流程本身也是绿的状态,资源被打包、脚本被压缩,没有一条错误提示。所以我当时把问题完全锁定在“微信开发者工具的环境配置”上,先怀疑基础库版本,又怀疑ES6转ES5没开。
结果换成微信开发者工具打开构建产物,看到的是:
- 启动白屏,页面完全没有渲染出任何内容。
- Console第一行报错是
ReferenceError: localStorage is not defined,但这个报错下面还跟了一大串来自引擎内部的报错,属于典型的“首错引发连锁”。 - 藏着几个完全不相关的白屏:某个AI生成的数据读取函数在拿到
undefined后直接抛异常,异常中断了后续的初始化流程。
看起来像是几个问题,实际上是一根链条:第一个环境API调用崩了,后续所有依赖它的模块全部炸掉,最终表现成白屏。这个链条在单元测试里完全无法复现,因为我的Mock里wx.getStorageSync总是返回我预置好的字符串。
2.2 数据丢失和异步时序:比直接报错更难缠的问题
等把白屏问题临时修掉,第二批问题马上暴露出来,这批不是显式报错,而是静默失败。
首先是存档丢失。玩家在某一局里选择了三件事,次日再进游戏,选择记录清零。查日志发现是wx.setStorageSync写入失败,但失败没有进入catch,而是被当成一次“正常返回”处理了。原因是AI生成的存储模块写得比较粗糙,直接用了localStorage.setItem和localStorage.getItem,数据体积一大或者写入时机不对,就出现覆盖和丢失的情况。
其次是异步时序问题。玩家点开某条待办详情时,偶尔会触发“未定义属性”的错误。排查后确认是分包加载导致的:详情页放在分包里,玩家在主页快速点击进详情时,分包脚本和资源还没加载完,详情页的初始化代码却已经在执行了。这种时序问题是单元测试完全测不出来的,因为Jest环境里所有模块都是同步require,不存在加载延迟。
第三个是分享回调拿不到参数。分享功能本身能用,但分享后回到游戏,回调事件里带上的一些自定义参数在我们的代码里怎么都读不到。原因也很典型:AI实现分享时用了wx.showShareMenu加wx.onShareAppMessage,但回调里读取参数用的键名和分享时放进去的键名不一致,而且它把参数藏进了某个自己臆想出来的全局变量里。这个在测试环境里没法查,因为Mock的分享回调只是返回一个固定对象。
3. 顺着报错挖根因:完整排查链路复盘
3.1 从“怀疑构建配置”到翻构建产物
版本的完整排查,我一开始走了不少弯路。最先怀疑Cocos构建面板的参数,把“MD5 Cache”“Source Map”“Polyfills”全部过了一遍,甚至还试过换基础库版本,完全没用。顺着localStorage is not defined字样在网上搜了一圈,发现大家都说是老问题,但每个人给出答案都不是针对我们这个项目形态的,看了半天反而更晕。
真正有效的一步,是把构建产物翻出来直接看。
微信小游戏构建出来的JS位于build/wechatgame目录下。我打开目录里最大的那个js文件,搜索localStorage,一下就定位到了问题代码。那行代码来自AI生成的一个工具函数,函数名叫getClientTime,里面顺带做了个“顺便把今天的选择计数暂存一下”的操作,用的就是localStorage.setItem。而AI生成这段代码时,参考的恐怕是大量Web项目的语料——浏览器里当然有localStorage,但微信小游戏运行环境没有。
看到这里,所有“构建配置”的怀疑都消失了。问题很明确:我们的代码用了一个微信小游戏环境根本不存在的全局对象。
3.2 用全局搜索把环境依赖一个个揪出来
找到第一个问题后,我意识到这绝不可能只有一处。于是开始在构建产物里做全文本搜索,把所有浏览器环境专属的全局变量全部捞出来。搜索词包括但不限于:
window.document.localStoragesessionStorageperformance.navigator.XMLHttpRequestalert(
结果非常刺激。构建产物里一共有12处引用了这些环境专属对象,分布于6个模块。其中4处是AI生成的,2处是我自己早期从Web项目里拷贝过来的历史代码。这些调用在微信开发者工具里,每一处都是潜在的运行时炸弹。只是它们不一定都以“is not defined”的形式立刻爆炸,有的藏在某个回调函数里,只有在特定用户操作触发时才炸。
我还额外检查了Date相关的时区假设,好在没踩坑。但performance.now()确实是踩中了,在某个统计模块里,AI为了追求高精度时间戳,用了performance对象,而在微信小游戏环境里这个对象不是默认提供的。我们测试环境用的Node版本自带performancepolyfill,所以跑用例时压根没报错。你看,这就是“全绿”为什么不可信的最好证据:连测试运行环境本身都可能和线上环境不一致。
3.3 顺着生命周期和存储接口定位另外两处暗雷
环境全局变量清完之后,白屏和首错问题被解决,但数据丢失和时序问题还在。这两类问题无法靠搜索全局变量找出来,因为它们不报错,只产生错误结果。于是我开始逐条跟踪“存储写入”和“页面初始化”两条链路。
存储这条链路,最终定位到两个坑。
第一个坑是AI生成的数据序列化格式统一性问题。某段代码写入时会先JSON.stringify再存,但另一段代码读取时直接JSON.parse读出来的原始值,如果wx.getStorageSync返回的是一个已经反序列化过的对象,JSON.parse就会抛异常;如果不巧抛异常,代码也没做兜底,直接返回了null,于是玩家记录被当成“无记录”处理。
第二个坑是微信存储的容量限制。单测里Mock的存储是无限内存,永远不会触发容量告警。真机上的wx.setStorageSync对单key和总容量都有上限要求,一旦超过,写入直接失败。而AI生成的代码把整个统计历史放在了同一个key下面,数据量一大就静默失败。
生命周期这条链路则更微妙。页面onLoad阶段会先尝试读取某些配置数据,但这些数据其实是分包里的脚本运行后才写入的。在开发者工具本地调试时,因为本地资源加载快,时序歪打正着地躲过去了;真机上分包加载稍慢,就出现偶尔读到空数据的偶发Bug。
4. 测试全绿但环境失败:三类典型盲区拆解
4.1 过度Mock:你测的其实不是你要发布的那套代码
第一类盲区,也是最常见的一种,就是测试环境被人为“修得太好”。
单元测试的本质是把被测单元的依赖替换成可控替身,从而聚焦在逻辑本身。这个思路本身没有问题,但当我连wx、存储、引擎生命周期都要Mock掉时,测试就已经从“验证代码在真实运行环境里能不能工作”变成了“验证代码在Mock里能不能工作”。某种意义上,Mock就像是给代码定制了一件安全服,穿上它的时候所有行为都符合预期,脱下来面对真实环境时,处处都是意外。
我后来总结了一句口诀:Mock只该管“外部副作用”,不该管“环境基础能力”。存储、请求、分享、文件系统这些都是环境能力,与其Mock,不如在测试里引入真实的兼容层封装,让测试跑在更接近真机的实现上。当然这不是让测试连微信SDK,而是让Mock的返回值、返回类型、失败场景都尽量贴近真实API的行为。
4.2 AI训练语料的“浏览器惯性”:越“标准”的代码越危险
第二类盲区,属于AI辅助开发的专属问题:AI生成代码时,默认掉进了浏览器语料的惯性里。
这很容易理解——整个互联网上开源的JavaScript代码,绝大多数都跑在浏览器环境里。window、document、localStorage、performance这些都是AI见过一万次的写法,它们出现在生成结果里的概率天然就高。更有意思的是,AI写出来的这类代码通常还非常“标准”,看起来就是一本正经的教科书写法,放在Web项目里完全没毛病。
但微信小游戏不是网页。它运行在特殊的脚本环境里,没有BOM和DOM,没有window,也没有localStorage。问题出在:一个训练语料以Web为主的模型,不会主动意识到“当前环境是微信小游戏”,除非你在提示词里非常明确地掐死这些边界。AI不会怀疑localStorage是否存在,它只会认为这是最普通的参数。我在复盘时给这种现象起了个名字,叫“越标准越危险”——AI写的函数读起来越规范、越无可挑剔,越说明它很可能在生搬硬套浏览器的标准环境。
4.3 时序、资源配额、系统API差异:静态单测测不出的边界
第三类盲区,是单测天然测不到的那些“运行时有形条件”。
我把它们放在了一起:异步加载时序、资源体积配额、平台专属API差异。这些条件有一个共同点——它们不在代码逻辑里,而在代码的运行环境里。逻辑正确代表的是“如果一切条件都按预期出现,则结果正确”;但真实环境里,条件经常不按预期出现。微信小游戏的运行环境是一个受限容器,存储有配额、单包有体积上限、分包加载有延迟、渲染性能有天花板,这些限制没有任何一个会被Jest用例捕捉到。
比如我们遇到的详情页闪进闪出。在本地测试中,所有分包脚本都随主包一并加载,资源瞬间就位。但在真机上,分包加载也许需要几百毫秒,玩家如果在这个窗口期内触发依赖分包数据的操作,就会得到未初始化的数据。再比如canvas的toDataURL,在微信小游戏里根本不受支持,得用toTempFilePath,这种API差异在单元测试里无法体现,必须真机验证。
三类盲区我总结成一张表,方便大家对照自己项目里的情况:
| 盲区类型 | 单元测试环境表现 | 微信小游戏真实环境表现 |
|---|---|---|
| 过度Mock | Mock的存储永远有空间,返回类型固定 | 存储有上限,写入可能静默失败 |
| AI语料惯性 | 代码里的window/localStorage没被发现 | 直接ReferenceError,引发白屏链 |
| 时序与配额 | 所有模块同步加载,资源即时可用 | 分包有延迟,资源有体积压力,性能有天花板 |
你如果把这张表贴在自己的冒烟测试清单旁边,会发现它能预判掉大量“全绿但上线挂”的坑。
5. 修复落地:从补丁式修复到建立防再犯机制
5.1 存储模块统一收口:平台差异被关进一个文件
第一波修复很直接,把所有直接调用localStorage、window的地方全部改掉。但靠人工逐个改肯定治标不治本,所以我把存储功能统一收口到一个storage.js模块里,未来谁都不许绕过它直接碰平台API。
// storage.js —— 平台差异统一收口 import { sys } from 'cc'; function getNativeStorage() { if (typeof wx !== 'undefined' && wx.getStorageSync && wx.setStorageSync) { return wx; } return null; } export function save(key, value) { const str = JSON.stringify(value); const nativeStorage = getNativeStorage(); if (nativeStorage) { try { nativeStorage.setStorageSync(key, str); } catch (e) { console.warn('存储写入失败', key, e); } return; } sys.localStorage.setItem(key, str); } export function load(key, fallback = null) { const nativeStorage = getNativeStorage(); let raw = null; if (nativeStorage) { raw = nativeStorage.getStorageSync(key); if (raw === '' || raw === undefined || raw === null) { return fallback; } raw = typeof raw === 'string' ? raw : JSON.stringify(raw); } else { raw = sys.localStorage.getItem(key); } try { return JSON.parse(raw) ?? fallback; } catch (e) { return fallback; } }这个模块的设计思路是:
- 先判断
wx是否存在,存在就走微信原生接口;不存在就走Cocos封装的sys.localStorage,保证预览器和开发者工具都能跑。 - 写入前统一
JSON.stringify,读取时对非字符串类型做兼容处理,因为在微信接口下,某些版本会直接返回对象,直接JSON.parse反而会炸。 - 读取失败一律返回
fallback,绝不让数据异常中断初始化流程。
这个模块只做一件事:让“存储”这个词在代码里只对应一个稳定的接口。以后AI生成的任何代码,我都要求它必须引用这个封装,不许再碰底层API。
5.2 把环境依赖检查做成构建前置门禁
只靠代码审查约束AI是不可靠的,所以我写了一个静态扫描脚本,专门检查构建产物里是否残留浏览器环境专属调用。它被挂进构建流程,只要扫到,就fail掉构建。
// scan-env-deps.js const fs = require('fs'); const path = require('path'); const distDir = path.join(__dirname, 'build/wechatgame'); const bannedPatterns = [ /\bwindow\./, /\bdocument\./, /\blocalStorage\b/, /\bsessionStorage\b/, /\bperformance\./, /\bnavigator\./, /\bXMLHttpRequest\b/, /\balert\(/ ]; let found = 0; function walk(dir) { for (const file of fs.readdirSync(dir)) { const full = path.join(dir, file); const stat = fs.statSync(full); if (stat.isDirectory()) { walk(full); } else if (full.endsWith('.js')) { const content = fs.readFileSync(full, 'utf8'); for (const pattern of bannedPatterns) { const match = content.match(pattern); if (match) { found++; const line = content.slice(0, match.index).split('\n').length; console.log(`[环境依赖] ${full}:${line} -> ${match[0]}`); } } } } } walk(distDir); if (found > 0) { console.log(`发现 ${found} 处环境依赖调用,请在代码中修复。`); process.exit(1); }别小看这个脚本,它把“环境合规检查”从人工环节变成了自动化环节。每次构建都像有一双眼睛盯着代码,一旦AI写出一行window.xxx,构建就会当场失败,而不是等微信开发者工具跑起来才发现。
5.3 测试策略重新划界:逻辑归逻辑,环境归冒烟
修复完代码后,我重新调整了测试策略。核心原则是:单元测试只承担逻辑验证,环境验证交给另一层测试。
单元测试继续保留,但Mock策略升级:
- 存储模块不Mock,改成引用真实的
storage.js,只把最底层的wx接口替换成一个状态完整的假实现,让测试能感知到写入、读取、失败的状态变化。 - 所有和生命周期、异步资源加载相关的逻辑,一律不写单测,改成集成用例。
- 凡是依赖环境API的代码,必须经过真实环境冒烟测试。
冒烟测试的做法是:在微信开发者工具里跑一份自动化脚本,覆盖“初始化、生成清单、完成一项、忽略一项、退出再进入、分享回调”这6条核心路径。Cocos项目跑开发者工具自动化脚本稍微麻烦一点,但好处是它能真正验证代码在微信运行时的表现。这层测试没法进CI,我把它做成了每次发版前的固定动作。
这么一来,“全绿”这个词的含义就变了:它只代表逻辑正确,不代表环境正确。而环境正确性的信号,由另一套机制给出。
6. AI辅助开发复盘:以后我再也不会被“全绿”骗了
6.1 AI为什么总在环境边界上保持乐观
这次踩坑下来,我对AI辅助开发的理解比之前务实了很多。AI确实提效,尤其在写纯逻辑、纯算法、纯数据处理的时候,质量高、速度快,因为有大量公开代码样本可以参照。它的短板在于环境边界——AI没有“我当前正在写一个微信小游戏脚本”这种上下文感知,它更倾向于复用最常见的Web通用写法。
更让我警惕的是,AI在不确定环境是否支持某个API时,会选择自信地直接写出来,而不是先问一句“这里环境对吗”。这可能是因为训练语料里“直接使用localStorage”的例子数量碾压“先判断环境再使用”的例子数量。所以我的提示词里从此多了一条硬性规则:不确定某个API在当前平台是否存在时,先说明风险,不要自行脑补兼容层。
6.2 我们总结的AI辅助开发防坑清单
每次让AI生成新模块,我都会在提示词里贴一段环境约束说明,效果非常明显。这段说明后来被我们小组起名为“小游戏环境咒语”:
- 运行时没有
window、document、localStorage、performance、navigator。 - 持久化数据优先使用项目内的
storage.js,禁止直接调用底层存储接口。 - 跨端能力一律使用项目内的平台封装模块,未封装的功能先询问。
- 生成的代码要能在微信开发者工具的基础库环境下运行,不能依赖浏览器特性。
- 涉及异步资源加载时,默认按“资源可能还没就绪”来写防御逻辑。
除了提示词,我们还定了两条审查规矩:第一,AI生成的代码必须做“环境审查”,专门检查有没有平台专属API的误用,和逻辑审查分开,因为眼睛在看逻辑时,很容易放过环境问题;第二,不要因为测试全绿就给AI代码放行,全绿只是入场券。
6.3 一点关于工具链的想法
回到标题里的那个问题:测试全绿,为什么进Cocos还是失败?现在我们有了明确的答案——因为测试全绿验证的是“逻辑在Mock环境里成立”,而进Cocos构建、进微信小游戏环境失败,是因为它验证的是“代码在受限环境中成立”。两者之间的差值,就是所有坑的藏身之处。
如果非要给AI辅助开发下的工具链提点建议,我强烈建议大家把“环境静态扫描”和“真机冒烟用例”当成标配,而不是可选项。AI写代码的能力越强,这些防御机制就要越重。因为AI不会记住平台边界,它只会顺着语料的惯性走,而你需要用机制帮它踩刹车。
这次修完《清单之外》之后,我把扫描脚本和提示词咒语都存进了团队模板库,下次新项目直接用。至少到现在,我们新的小游戏项目已经连续跑了好几轮真机预览,没再出现过“全绿但白屏”这种让人血压拉满的情况。如果你也在用AI帮自己写Cocos小游戏,第一件事不是催它写功能,而是先把环境合规的门禁立起来。