主题
第 21 章:测试、调试、日志与 QA
建立静态契约、真实编辑器试玩、日志归因、截图和回归测试证据链。
你会学到什么
- 如何用期望日志定位玩法链路。
- 如何按 editor-cli 跑测闭环验证修改。
- 如何用 play session 精确读取日志,并在结束后形成完整证据。
- 如何区分运行时错误、业务日志和 UI 截图问题。
- 如何用 EUI testspec + 截图做回归测试。
如何阅读本章
本章不是要求你一次搭完完整测试平台。第一轮只需要掌握“写期望日志、预检、跑一轮、按 session 读日志、停止并复核、修复后重跑”;当项目开始接入 UI、存档、资产或平台服务时,再回来看 testspec、截图审查和发布取证目录。
如果你正在排一个具体 bug,可以直接从“复杂链路分层定位”开始;如果你准备发布或交给别人评审,再补齐“跨章节失败复盘清单”和“发布取证目录”。这样本章既能当学习章节,也能当真实项目的排错手册。
QA 的基本心智
写完代码不等于功能完成。你需要证明三件事:
| 证明目标 | 证据 |
|---|---|
| 脚本没有运行时错误 | editor-cli log trace --play-session ... 没有语义错误 |
| 玩法链路按预期发生 | 期望日志按顺序出现 |
| UI 真的显示正确 | testspec、断言日志、试玩中截图审查都通过 |
第一轮学习不用追求复杂自动化,但要养成固定顺序:预检 → 启动并确认 ready → 复现 → 按 session 查日志/截图 → 停止并确认 idle → 复核 → 修复 → 重跑。
日志文件的实际路径由 editor-cli status 的 log_file 返回,不要假定项目根目录一定有 log.txt。日志可以在试玩中按 session 查询;停止试玩的意义是关闭本轮时间窗口、释放运行态并确保下一轮从 idle 开始,而不是“只有停止后日志才可读”。
日志规范
好的日志不是越多越好,而是能证明关键节点发生了:
lua
-- @runtime server
local function LogRoundStarted(gameEndTime)
if type(gameEndTime) ~= "number" then
print("[SE Lua Guide][WARN] 回合结束时间无效")
return
end
print("[SE Lua Guide][Round] started endTime=", gameEndTime)
end
local function LogScoreChanged(player, score)
if player == nil or not player:IsA("Player") or type(score) ~= "number" then
print("[SE Lua Guide][WARN] 计分日志参数无效")
return
end
print("[SE Lua Guide][Score] player=", player:GetName(), "score=", score)
end规则:
- 使用统一前缀,例如
[SE Lua Guide]。 - 把模块和动作写进固定标签,例如
[Round] started,方便精确检索;不要只靠自然语言句子。 - 成功路径和失败路径都要有日志。
- Heartbeat 中必须节流,不能每帧刷屏。
- 临时诊断日志解决问题后要删除,保留关键业务日志。
- 不要记录预留服务器访问码、订单凭据、完整存档或其他敏感数据。
期望日志驱动开发
写功能前先写“我期待看到什么日志”:
text
[SE Lua Guide] 回合开始
[SE Lua Guide] 碰撞检测触发
[SE Lua Guide] 玩家识别成功: <name>
[SE Lua Guide] 当前得分: 1
[SE Lua Guide] 回合结束运行后对比实际日志:
| 缺哪条日志 | 常见原因 |
|---|---|
| 回合开始 | 初始化没执行、main.lua 未挂载、require 路径错 |
| 碰撞检测触发 | 对象没找到、事件没绑定、碰撞/触发配置错误 |
| 玩家识别成功 | otherUnit 不是角色,需要检查碰撞对象 |
| 当前得分 | 服务端计分逻辑没走到,或被防抖/校验拦截 |
| UI 更新 | RemoteEvent 未定义一致,或 client 节点没找到 |
复杂链路分层定位
复杂功能出问题时,不要只问“是不是代码错了”。先把链路拆成可观察的层:
| 层 | 典型证据 | 常见问题 |
|---|---|---|
| 场景对象 | 启动日志打印对象名、GetFullPath()、Position | 命名不一致、对象未加载、父节点不对 |
| server 事件 | OnCollisionEnter / OnServerEvent 进入日志 | 碰撞配置错、客户端没发请求、事件名不一致 |
| server 判定 | 校验通过/拒绝日志、分数变化日志 | 防抖过严、玩家识别失败、权限校验漏了 |
| RemoteEvent 下发 | FireClient / FireAllClients 前后的日志 | 目标玩家错、单个 table payload schema 不一致 |
| client 接收 | OnClientEvent 进入日志 | client 脚本未加载、require 路径错 |
| UI 表现 | 节点存在日志、截图、testspec | 仍依赖旧节点映射、节点 Name 不稳定、布局遮挡 |
| 存储/排行 | pcall 成败、默认值、最终榜单 | Async 裸调用、GetAsync + SetAsync 覆盖、OrderedDataStore 值非整数 |
比如“技能命中后 UI 不更新”,应该先证明 server 已判定命中,再证明 server 已下发事件,再证明 client 收到事件,最后才看 UI 节点。这样能避免在 UI 里改半天,结果根因是服务端根本没有通过冷却校验。
失败记录模板
每次修复杂问题,建议留下一个短记录,方便回归:
md
### 失败记录:碰撞计分不增长
- 复现步骤:启动试玩,玩家推动「球体」撞「方块」。
- 期望结果:服务端打印“碰撞检测触发”,分数从 0 变 1,HUD 更新。
- 实际结果:只看到“碰撞检测触发”,没有“LastOwnerUID”。
- 分层定位:server 碰撞事件已进入;玩家识别层失败。
- 根因:球体碰到方块前没有触碰角色,`LastOwnerUID` 未写入。
- 修复:在球体触碰对象先确认 `otherUnit:IsA("EggyUnit")`,再用 `Players:GetPlayerFromCharacter(otherUnit)` 写属性。
- 回归:重跑同一流程,日志出现“加分后: 1”,截图中 HUD 显示 1。失败记录不是给别人看的仪式感,它能迫使你区分“已验证事实”和“猜测”。当同类问题第二次出现时,你也能直接复用分层定位路径。
跨章节失败复盘清单
正式公开前,建议每个 Part 至少补一条真实失败复盘。复盘不要求问题很大,但必须有复现步骤、日志或截图、定位过程和重跑结论。
| 范围 | 建议复盘问题 | 关联章节 | 证据落点 |
|---|---|---|---|
| Part 1 | 对象找不到、碰撞不触发、Timer 重复触发 | 02、05、06 | evidence/editor-runs/ |
| Part 2 | RemoteEvent 单载荷 schema 不一致、UI 节点 Name 不一致、DataStore 失败 fallback | 08、09、11 | evidence/editor-runs/ + evidence/screenshots/ |
| Part 3 | 动画播放了但技能没判定、相机 Raycast 方向写错 | 14、15 | evidence/editor-runs/ |
| Part 4 | custom:// 加载失败、preset 忘记 Parent | 17、18 | evidence/editor-runs/ |
| Part 5 | 平台 Async 裸调用、Capstone 中途加入 HUD 不更新 | 20、22 | evidence/editor-runs/ + evidence/screenshots/ |
这些复盘会让教程从“展示正确写法”升级为“教读者如何把错误修到正确”。如果只能先补一条,优先补第 22 章 Capstone 的中途加入或 UI 节点缺失,因为它同时覆盖 server、client、RemoteEvent、EUI 和 QA。
editor-cli 跑测闭环
editor-cli 用于选择编辑器实例、做只读预检、控制试玩、注入临时代码、读取 session 日志和截图。当前默认安装位置是用户目录下的 .eggitor/cli/editor-cli.exe。以下 PowerShell 先保存路径和目标实例;如果同时开了多个编辑器,优先用 PID 或 HTTP port,避免同名地图选错:
powershell
$editorCli = Join-Path $env:USERPROFILE ".eggitor\cli\editor-cli.exe"
& $editorCli editor-instances list --json
# 替换成上一步返回的准确 map_name、pid 或 http_port。
$editorTarget = "your-map-name-or-port"
& $editorCli --editor-instance $editorTarget status --json1. 先做只读预检
powershell
& $editorCli --editor-instance $editorTarget preflight `
--capabilities connection,map,play,screenshot `
--expect-state idle --json只有 JSON 中 ready 为 true 才进入下一步。preflight 不会启动/停止试玩、保存地图或同步代码。
如果本轮还要同步 Lua 工程,先额外检查 code-sync 并查看双向 diff;发现 only-local、only-map 或 differs 时先确认所有权,不得机械 push / pull 覆盖他人改动:
powershell
& $editorCli --editor-instance $editorTarget code diff `
--workspace "path-to-your-lua-workspace" --json2. 开始试玩
powershell
& $editorCli --editor-instance $editorTarget play start `
--wait-ready --wait-timeout 30 --json保存返回的 play_session。--wait-ready 会等待游戏运行时实际执行 Lua 探针;没有这个参数时,成功只代表启动请求已提交。不要用固定 sleep 猜测地图是否加载完成。
3. 复现问题
按本次测试目标操作:
| 测试目标 | 操作 |
|---|---|
| 碰撞计分 | 推球撞方块 |
| UI 更新 | 触发服务端分数变化 |
| 按钮请求 | 点击按钮 |
| 存档 | 完成一局后离开或触发保存 |
4. 试玩中按 session 查日志和截图
powershell
$playSession = "play-start-returned-session"
& $editorCli --editor-instance $editorTarget log grep "\[SE Lua Guide\]" `
--play-session $playSession --max-matches 200 --json
& $editorCli --editor-instance $editorTarget log trace `
--play-session $playSession --json
& $editorCli --editor-instance $editorTarget screenshot game `
--output "evidence\screenshots\qa-current.png" --no-clobber --json截图必须在游戏窗口仍处于试玩状态时采集。log grep 负责查期望 marker,log trace 负责提取语义错误;不要只搜一两个英文错误片段就断言“没有错误”。
5. 停止试玩并确认 idle
powershell
& $editorCli --editor-instance $editorTarget play stop --json
& $editorCli --editor-instance $editorTarget status --jsonplay stop 成功只确认停止请求。继续轮询 status,直到同时满足:
text
edit_mode = true
in_game_runtime = false
editor_state = idle6. 复核本轮完整日志
停止后继续用同一个 play_session 做最终复核,避免上一轮或编辑器启动日志污染结论:
powershell
& $editorCli --editor-instance $editorTarget log trace `
--play-session $playSession --json
& $editorCli --editor-instance $editorTarget log grep `
"\[SE Lua Guide\]\[(WARN|ERROR)\]" `
--play-session $playSession --max-matches 200 --json需要人工浏览时可再检索:
text
attempt to
stack traceback
not found
encoding is not valid
[SE Lua Guide][WARN]但发布结论应保存结构化命令结果、session ID、期望 marker 和截图,不要只写“看过日志没问题”。
7. 修复并重跑
每轮只改和诊断一致的最小范围。修复后重新执行完整闭环,不要只凭代码看起来合理就结束。
text
preflight ready → play start --wait-ready → 复现 → session 日志/截图 → play stop → idle → 最终复核 → 修复 → 重跑试玩中执行临时代码
试玩运行时可以通过 editor-cli 注入 Lua 查询状态:
powershell
& $editorCli --editor-instance $editorTarget exec `
"print('[SE Lua Guide][QA] probe=hello')" `
--runtime server --json
& $editorCli --editor-instance $editorTarget log grep `
"\[SE Lua Guide\]\[QA\] probe=hello" `
--play-session $playSession --platform server --json注意:
exec --runtime server/client是异步提交;命令成功不等于片段后来执行成功。- 用唯一 marker 写入日志,再按本轮
play_session回读;查到 BEGIN 而没有 END 也不能算通过。 exec能执行任意 Lua,属于变更操作。注入前先审查副作用,数据存储、交易、传送和分析上报只在隔离环境执行。- 复杂代码优先写入临时 UTF-8 文件,再使用
exec --file ...,避免多层引号转义错误;临时文件放项目tmp/。
pcall:包住可能失败的操作
网络、资源加载、数据存储、排行榜等都可能失败。用 pcall 捕获错误,避免脚本中断:
lua
-- @runtime server
local DataStoreService = game:GetService("DataStoreService")
local Task = game:GetService("Task")
if DataStoreService == nil or Task == nil then return end
local store = DataStoreService:GetDataStore("qa-example")
local function ReadForQa(key)
if type(key) ~= "string" or key == "" then return end
Task:Spawn(function()
local ok, result = pcall(function()
return store:GetAsync(key)
end)
if ok then
print("[SE Lua Guide][QA] datastore_read=success")
else
print("[SE Lua Guide][WARN] datastore_read=failed error=", result)
end
end)
end不要用 pcall 吞掉所有错误后什么都不打印。失败日志是 QA 的证据;同时不要把读取到的完整玩家数据打进日志。DataStore 具有外部副作用和真实数据风险,这段只在隔离测试存储中做行为验证,普通地图仅做契约校对与编译。
LogService
LogService 可用于测试代码监听运行日志:
lua
-- @runtime client
local LogService = game:GetService("LogService")
if LogService == nil then return end
LogService.MessageOut:Once(function(message, messageType)
print("[SE Lua Guide][LOG]", messageType, message)
end)
LogService:Info("qa_probe", {
module = "tutorial",
})普通玩法代码不一定需要监听 LogService。自动化测试、错误收集、调试工具更常用它。这里必须用 Once:如果用永久 Connect,又在回调里继续 print,新日志可能再次触发同一个回调,形成递归日志风暴。长期监听器应避免在回调里再次产生日志,并在销毁时断开连接。
EUI testspec + 截图审查
UI 测试不能只看断言。断言全部 PASS,也可能出现文字被裁切、按钮偏位、颜色看不清等问题。
推荐顺序:
- 读取
test/testspec_{module}.md,确认节点、交互和设计意图。 - 启动试玩并注入测试。
- 按本轮
play_session轮询日志,直到出现[TEST:END]或错误。 - 试玩仍运行时截取 game 窗口;否则只能得到编辑态画面或截图失败。
- 先检查断言与
log trace,确认测试真正跑到 END 且没有语义错误。 - 停止试玩并确认 idle,再用同一 session 做最终日志复核。
- 查看刚才保存的截图,逐项检查视觉;日志 PASS 不能替代视觉验收。
截图审查至少检查:
| 检查项 | 判定标准 |
|---|---|
| 文字可读性 | 不被遮挡,对比度足够 |
| 布局合理性 | 居中/贴边/对齐符合 testspec |
| 无异常重叠 | 元素没有意外互相盖住 |
| 无裁切异常 | 文字和图片没有被截断 |
| 颜色与样式 | 状态色、透明度、禁用态正确 |
| 无渲染异常 | 没有黑块、白块、拉伸变形 |
如果截图异常,即使断言 PASS,也要修 UI 或 testspec。
发布取证目录
如果本系列要从“内部评审版”推进到“正式公开版”,真实编辑器证据需要落到固定目录,避免散在聊天记录或个人截图里:
| 目录 | 放什么 | 最小要求 |
|---|---|---|
evidence/editor-runs/ | 真实编辑器运行日志摘要、复现步骤、开始/停止试玩记录 | 每条记录写清章节、参考代码路径、操作步骤、期望日志、实际日志和结论,并标记 status: passed |
evidence/screenshots/ | 生成工程、输出窗口、UI 节点、运行效果截图 | 每张截图配同名 .md,说明截图对应章节和通过标准,并标记 status: passed |
evidence/api-review/ | 人工 API 终审记录 | 写明 reviewer、日期、章节/API、结论和遗留问题,并标记 status: approved |
可以按本章表格自建模板;模板只是起点,不会被发布就绪缺口审计计入有效证据。只要真实编辑器运行、截图或人工 API 终审证据没有按模板落盘并通过,文档就只能标记为内部评审版。
一个 testspec 至少要描述三类信息:
md
# testspec_hud.md
## 节点
| Name | 类型 | 用途 |
|---|---|---|
| label_time | EUITextLabel | 显示倒计时 |
| label_point | EUITextLabel | 显示当前分数 |
## 交互/数据
- 收到 `HudSnapshot { phase, endTime, serverTime, score }` 后,`label_time` 按 server 时间基准刷新。
- 同一份 `HudSnapshot` 中的 `score` 更新后,`label_point` 显示最新分数。
- 节点缺失时打印 WARN,不抛运行时错误。
## 截图验收
- 两个文本不重叠。
- 数字变化后仍完整显示。
- 低分辨率下不会被边缘裁切。最小排错示例:碰撞计分不增长
1. 写期望日志
text
[SE Lua Guide] 碰撞检测触发
[SE Lua Guide] LastOwnerUID: <uid>
[SE Lua Guide] 加分前: 0
[SE Lua Guide] 加分后: 12. 插入临时诊断日志
lua
-- @runtime server
local function AttachScoreDiagnostics(cube, ball, scores)
if cube == nil or not cube:IsA("WorldUnit") then return nil end
if ball == nil or not ball:IsA("WorldUnit") then return nil end
if type(scores) ~= "table" then return nil end
return cube.OnCollisionEnter:Connect(function(otherUnit)
local otherName = otherUnit and otherUnit.Name or "<unknown>"
print("[SE Lua Guide][Score] collision other=", otherName)
if otherUnit ~= ball then
print("[SE Lua Guide][WARN] score_collision=ignored reason=not_ball")
return
end
local uid = ball:GetAttribute("LastOwnerUID")
print("[SE Lua Guide][Score] last_owner=", uid)
if type(uid) ~= "string" or uid == "" then
print("[SE Lua Guide][WARN] score_collision=ignored reason=missing_owner")
return
end
local before = scores[uid] or 0
scores[uid] = before + 1
print("[SE Lua Guide][Score] before=", before, "after=", scores[uid])
end)
end这个函数只负责临时诊断,调用方必须保存返回的 Connection,排查结束或系统销毁时调用 Disconnect()。它假定 LastOwnerUID 已由另一条经过 EggyUnit 类型检查的角色触碰链路写入;诊断日志不能代替那条权威判定。
3. 运行闭环判断
| 现象 | 下一步 |
|---|---|
| 没有“碰撞检测触发” | 检查 cube 是否找到、碰撞是否启用 |
| 打印“碰到的不是球体” | 检查事件绑定对象和碰撞对象 |
LastOwnerUID 为 nil | 回到球体碰撞玩家的逻辑,检查 SetAttribute |
| 加分后变了但 UI 不变 | 检查 RemoteEvent / client HUD |
常见错误
错误:不带 session 读取一大段混合日志
试玩中可以安全读取日志;错误在于不限定 play_session,把上一轮残留错误或编辑器日志算进本轮。运行中用 session 查询 marker 与错误,停止后再用同一 session 做最终复核。
错误:只看最后一行报错
真正的错误源通常在 stack trace 中间。要看调用栈中第一个指向自己脚本的文件和行号。
错误:没有复现步骤
“有 bug”不是测试用例。至少写清:启动后做什么、期待什么、实际看到什么。
错误:UI 只看 PASS,不看截图
UI 的目标是玩家看得见、点得到、读得懂。截图审查是 UI QA 的主力证据。
错误:修复后不重跑
修复必须用同一复现步骤重跑确认。只改代码不重跑,不能证明问题已解决。
练习任务
- 给第 6 章碰撞示例写一份期望日志清单。
- 用 editor-cli 完整跑一轮:只读 preflight、
play start --wait-ready、复现、按 session 查日志/截图、停止并确认 idle、最终复核。 - 给第 9 章 HUD 写
test/testspec_hud.md,并列出至少 3 个截图检查项。 - 找一个可能失败的 DataStore 或资产加载调用,用
pcall包裹并打印失败日志。 - 给第 22 章 Capstone 写一份失败记录,至少覆盖“中途加入玩家 HUD 不更新”或“UI 节点缺失”其中一个问题。
- 从“跨章节失败复盘清单”中任选一个问题,按模板写入
evidence/editor-runs/。
本章验收标准
- [ ] 我能写出功能的期望日志。
- [ ] 我会用
play_session隔离本轮日志,并在停止后做最终复核。 - [ ] 我能按 editor-cli 跑测闭环复现和验证问题。
- [ ] 我能用
pcall包住可能失败的操作,并保留失败日志。 - [ ] 我知道 EUI 测试必须结合 testspec、日志和截图。
- [ ] 我能把复杂问题拆成场景对象、server 判定、RemoteEvent、client 接收、UI 表现和存储几层定位。
- [ ] 我能把一次真实失败整理成可回归的证据记录。
本章产物
- 一份期望日志清单,用来驱动一次真实 playtest。
- 一份失败记录,包含复现步骤、实际日志、定位层级、修复动作和重跑结论。
- 一份截图或 testspec 审查记录,说明 UI 是否可读、无遮挡、节点命名稳定。
本章 API 对照
下一章预告
最后,让我们把前面所有章节的知识串起来,完成 Capstone 综合项目,并用本章的方法留下验收证据。
