Skip to content

第 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 statuslog_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、06evidence/editor-runs/
Part 2RemoteEvent 单载荷 schema 不一致、UI 节点 Name 不一致、DataStore 失败 fallback08、09、11evidence/editor-runs/ + evidence/screenshots/
Part 3动画播放了但技能没判定、相机 Raycast 方向写错14、15evidence/editor-runs/
Part 4custom:// 加载失败、preset 忘记 Parent17、18evidence/editor-runs/
Part 5平台 Async 裸调用、Capstone 中途加入 HUD 不更新20、22evidence/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 --json

1. 先做只读预检

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" --json

2. 开始试玩

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

play stop 成功只确认停止请求。继续轮询 status,直到同时满足:

text
edit_mode = true
in_game_runtime = false
editor_state = idle

6. 复核本轮完整日志

停止后继续用同一个 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,也可能出现文字被裁切、按钮偏位、颜色看不清等问题。

推荐顺序:

  1. 读取 test/testspec_{module}.md,确认节点、交互和设计意图。
  2. 启动试玩并注入测试。
  3. 按本轮 play_session 轮询日志,直到出现 [TEST:END] 或错误。
  4. 试玩仍运行时截取 game 窗口;否则只能得到编辑态画面或截图失败。
  5. 先检查断言与 log trace,确认测试真正跑到 END 且没有语义错误。
  6. 停止试玩并确认 idle,再用同一 session 做最终日志复核。
  7. 查看刚才保存的截图,逐项检查视觉;日志 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] 加分后: 1

2. 插入临时诊断日志

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 的主力证据。

错误:修复后不重跑

修复必须用同一复现步骤重跑确认。只改代码不重跑,不能证明问题已解决。

练习任务

  1. 给第 6 章碰撞示例写一份期望日志清单。
  2. 用 editor-cli 完整跑一轮:只读 preflight、play start --wait-ready、复现、按 session 查日志/截图、停止并确认 idle、最终复核。
  3. 给第 9 章 HUD 写 test/testspec_hud.md,并列出至少 3 个截图检查项。
  4. 找一个可能失败的 DataStore 或资产加载调用,用 pcall 包裹并打印失败日志。
  5. 给第 22 章 Capstone 写一份失败记录,至少覆盖“中途加入玩家 HUD 不更新”或“UI 节点缺失”其中一个问题。
  6. 从“跨章节失败复盘清单”中任选一个问题,按模板写入 evidence/editor-runs/

本章验收标准

  • [ ] 我能写出功能的期望日志。
  • [ ] 我会用 play_session 隔离本轮日志,并在停止后做最终复核。
  • [ ] 我能按 editor-cli 跑测闭环复现和验证问题。
  • [ ] 我能用 pcall 包住可能失败的操作,并保留失败日志。
  • [ ] 我知道 EUI 测试必须结合 testspec、日志和截图。
  • [ ] 我能把复杂问题拆成场景对象、server 判定、RemoteEvent、client 接收、UI 表现和存储几层定位。
  • [ ] 我能把一次真实失败整理成可回归的证据记录。

本章产物

  • 一份期望日志清单,用来驱动一次真实 playtest。
  • 一份失败记录,包含复现步骤、实际日志、定位层级、修复动作和重跑结论。
  • 一份截图或 testspec 审查记录,说明 UI 是否可读、无遮挡、节点命名稳定。

本章 API 对照

下一章预告

最后,让我们把前面所有章节的知识串起来,完成 Capstone 综合项目,并用本章的方法留下验收证据。