第 19 章 测试、调试与性能
第 19 章 测试、调试与性能
Lua 生态没有强制统一的测试框架。小型项目可以用一个断言驱动的最小测试器起步,再根据规模引入成熟的测试工具。
小型测试框架
local tests = {}
local function test(name, callback)
tests[#tests + 1] = {
name = name,
callback = callback
}
end
local function equal(actual, expected, message)
if actual ~= expected then
error(string.format(
"%s\nexpected: %s\nactual: %s",
message or "values are not equal",
tostring(expected),
tostring(actual)
), 2)
end
end
local function run()
local passed = 0
local failed = 0
for _, item in ipairs(tests) do
local ok, message = pcall(item.callback)
if ok then
passed = passed + 1
print("PASS", item.name)
else
failed = failed + 1
print("FAIL", item.name)
print(message)
end
end
print(string.format("%d passed, %d failed", passed, failed))
return failed == 0
end
测试任务模块:
local tasks = require("tasks")
test("添加任务会生成递增 ID", function()
local state = { nextId = 1, tasks = {} }
local first = assert(tasks.add(state, "first"))
local second = assert(tasks.add(state, "second"))
equal(first.id, 1)
equal(second.id, 2)
equal(state.nextId, 3)
end)
test("空标题被拒绝", function()
local state = { nextId = 1, tasks = {} }
local task, message = tasks.add(state, " ")
equal(task, nil)
equal(message, "标题不能为空")
end)
assert(run())
测试原则
-
一次测试只验证一个行为。
-
测试名描述行为和预期,而不是实现细节。
-
每个测试创建独立状态,避免相互污染。
-
覆盖成功、边界和失败路径。
-
文件系统测试使用临时目录,并在结束时清理。
-
不为私有实现制造大量测试,优先测试公开契约。
调试技巧
使用 traceback
local ok, message = xpcall(function()
error("something failed")
end, debug.traceback)
if not ok then
io.stderr:write(message, "\n")
end
debug.traceback 适合本地开发。面向用户输出时只展示稳定错误码,不要把完整内部堆栈暴露给外部。
打印结构化上下文
local function logError(operation, errorValue, context)
io.stderr:write(string.format(
"operation=%s error=%s request_id=%s\n",
operation,
tostring(errorValue),
context.requestId or "-"
))
end
日志字段需要避免换行注入。处理不可信字符串时,应进行转义或使用结构化日志协议。
复现步骤
调试问题时记录:
-
Lua 版本和宿主版本。
-
输入数据与最小复现脚本。
-
操作系统和文件系统差异。
-
随机种子或时间依赖。
-
模块版本和加载路径。
不要只记录“偶发失败”。没有可重复上下文的错误很难真正修复。
性能度量
先用真实负载测量,再优化。os.clock 测量 CPU 时间:
local started = os.clock()
-- 待测量代码
local elapsed = os.clock() - started
print(string.format("CPU: %.6f seconds", elapsed))
墙上时钟可以用于观察等待时间:
local before = os.time()
-- I/O 或外部操作
local after = os.time()
print("elapsed seconds:", after - before)
os.time 精度通常只有秒。需要更高精度时,使用宿主的单调时钟 API,不要依赖系统时间回拨。
基准测试
local function benchmark(label, iterations, callback)
local started = os.clock()
for _ = 1, iterations do
callback()
end
local elapsed = os.clock() - started
print(string.format(
"%s: %.6fs, %.2f us/op",
label,
elapsed,
elapsed * 1e6 / iterations
))
end
至少执行一次预热,并使用多次测量而不是单次结果。测量时关闭调试输出,避免把日志成本算进目标代码。
常见性能陷阱
反复连接字符串
-- 较慢
local result = ""
for index = 1, 10000 do
result = result .. tostring(index)
end
-- 较快
local parts = {}
for index = 1, 10000 do
parts[index] = tostring(index)
end
local result = table.concat(parts)
热点循环中的临时表
频繁创建临时表会增加垃圾回收压力。可以把可复用缓冲区作为参数传入,或者只在循环外创建一次。但不要为了微优化牺牲可读性,先确认内存分配真的是瓶颈。
无效缓存
缓存应具备明确键、容量和失效策略。无上限缓存会把内存问题从 CPU 转移到进程崩溃。
过早抽象
多层代理、元表查找和动态派发会让性能分析困难。对热点路径保留简单直接的实现,并写基准测试保护关键指标。
发布前检查清单
-
所有测试通过,错误路径有覆盖。
-
无调试打印、硬编码密钥或测试数据。
-
支持的最低 Lua 版本与 CI 一致。
-
依赖版本固定,许可证合规。
-
数据文件升级和回滚方案明确。
-
日志不包含密码、令牌和完整个人信息。
-
长任务支持超时或取消。
-
退出码、标准输出和标准错误符合工具约定。