为什么需要 Lua 测试工程化
Lua 以「轻量脚本」著称,很多项目把 Lua 代码当成一次性胶水,测试自然被忽略。但一旦脚本规模增长——比如 Neovim 插件、OpenResty 网关逻辑、游戏玩法脚本——没有自动化测试的 Lua 代码库很快会陷入「改一处、坏一片」的泥潭。
Lua 的测试工程化并不逊色于其他语言:busted 提供了成熟的 BDD 框架,luassert 提供了丰富的断言,luacov 提供覆盖率统计,三者配合 LuaRocks(见 LuaRocks 发布与 CI)可以构建完整的测试流水线。
# 通过 LuaRocks 安装测试三件套
luarocks install busted
luarocks install luassert
luarocks install luacov
安装与运行 busted
busted 是一个独立的可执行工具,也可以作为 Lua 模块被调用。最基本的用法:
busted # 运行当前目录及子目录的 spec
busted spec/ # 运行指定目录
busted spec/math_spec.lua # 运行单个文件
busted --verbose # 详细输出
busted --coverage # 集成 luacov 输出覆盖率
busted 默认会递归查找 *_spec.lua 文件(也可配置为 *_test.lua)。一个最小测试文件:
-- spec/math_spec.lua
describe("math 模块", function()
it("可以计算平方", function()
assert.is_true(4 * 4 == 16)
end)
end)
运行 busted 输出:
● math 模块
● 可以计算平方
1 success / 0 failures / 0 errors / 0 pending
BDD 语法基础:describe/it/assert
busted 的语法受 RSpec 启发,核心是 describe 与 it:
describe("...")组织测试分组,可嵌套。it("...", function() ... end)定义单个用例。pending标记尚未实现的用例。before_each/after_each在每个用例前后执行,before_all/after_all在整个分组前后执行。
local Calc = require("src.calc")
describe("Calc", function()
local calc
before_each(function()
calc = Calc.new()
end)
describe("#add()", function()
it("两个正数相加", function()
assert.are.equal(3, calc:add(1, 2))
end)
it("负数相加", function()
assert.are.equal(-3, calc:add(-1, -2))
end)
end)
describe("#divide()", function()
it("除数为零时返回 nil 与错误", function()
local ok, err = calc:divide(1, 0)
assert.is_nil(ok)
assert.is_string(err)
end)
end)
end)
# 前缀用于标记聚焦用例:busted --focus=#add 只运行标记了 #add 的分组,便于开发时快速反馈。
断言库 luassert
luassert 是 busted 的断言引擎,提供了大量语义化断言方法。
常用断言
describe("luassert 常用断言", function()
it("数值与类型断言", function()
assert.are.equal(42, 42)
assert.is_number(3.14)
assert.is_true(true)
assert.is_nil(nil)
assert.is_not_nil("x")
end)
it("table 断言", function()
assert.same({1, 2, 3}, {1, 2, 3}) -- 深比较
assert.is_array({1, 2, 3})
assert.has_key({a = 1}, "a")
end)
it("字符串断言", function()
assert.match("hello world", "world") -- 模式匹配
assert.has_prefix("prefix-x", "prefix")
assert.has_suffix("x-suffix", "suffix")
end)
it("错误与返回值", function()
assert.has_error(function() error("boom") end)
assert.has_error(function() error("boom") end, "boom")
end)
end)
自定义断言
luassert 允许扩展自定义断言,把重复的检查收敛成语义化表达:
-- 注册自定义断言
local luassert = require("luassert")
luassert.register("between", function(state, value, lo, hi)
return value >= lo and value <= hi
end)
describe("自定义断言", function()
it("数值在区间内", function()
assert.is_between(0.5, 0, 1)
assert.is_between(2, 1, 3)
assert.is_not_between(5, 1, 3)
end)
end)
自定义断言的核心是返回值:返回 true 通过,返回 false, "失败原因" 失败。这样可以把复杂的业务校验封装成可复用的断言。
异步与协程测试
Lua 中大量 IO 是异步的,busted 内置了对协程与异步回测的支持。在 Lua 协程深入解析 中我们介绍过协程的 yield/resume 机制,busted 利用它让异步测试写起来像同步:
-- 伪代码:测试一个异步 HTTP 客户端
describe("HttpClient", function()
it("异步请求可以返回结果", function()
local client = HttpClient.new()
-- async 使测试体可以阻塞等待
async(function()
local ok, body = client:get("https://example.com/")
assert.is_true(ok)
assert.has_prefix(body, "<html")
end)
end)
end)
busted 的 async() 会在协程中运行测试体,阻塞的 IO 通过事件循环恢复后继续执行,测试代码无需复杂的回调嵌套。
mock/stub/spy 行为验证
单元测试的关键是隔离被测单元的外部依赖。luassert 提供了一组行为验证工具:stub、spy 与 mock。
stub 与 spy
- spy:包裹一个函数,记录调用次数、参数、返回值,但不改变其行为。
- stub:替换一个函数/方法,可以自定义返回值或抛出错误。
- mock:stub + 预设期望,验证「是否被以预期方式调用」。
local MyService = require("src.my_service")
describe("MyService#fetch", function()
it("使用 stub 隔离外部请求", function()
-- stub 掉 MyService 内部依赖的 http 请求
local fetch = require("src.http").fetch
stub(fetch, function(url)
return { status = 200, body = '{"name":"lua"}' }
end)
local svc = MyService.new()
local result = svc:fetch("https://api.example.com")
assert.are.equal("lua", result.name)
assert.stub(fetch).was.called(1)
stub(fetch) -- 恢复原函数
end)
it("使用 spy 验证内部协作", function()
local notifier = require("src.notifier")
local spy_notify = spy.on(notifier, "notify")
local svc = MyService.new()
svc:save({ id = 1 })
assert.spy(spy_notify).was.called(1)
assert.spy(spy_notify).was.called_with({ id = 1 })
spy_notify:revert()
end)
end)
模块与对象 mock
对 Lua 模块级依赖,可以配合 require 缓存做整体替换:
local db = require("src.db")
describe("UserRepository", function()
it("保存用户时调用数据库插入", function()
-- 替换模块级 db 实现
local fake = {
insert = function(self, row)
self.last_row = row
end
}
stub(db, "connect").returns(fake)
local repo = require("src.user_repository")
repo:save({ name = "Tom" })
assert.are.equal("Tom", fake.last_row.name)
assert.stub(db.connect).was.called(1)
db.connect:revert()
end)
end)
行为验证的核心价值:测试关注的不是「结果恰好正确」,而是「组件之间按约定协作」。这在大型 Lua 代码库(如 OpenResty 网关的插件链)中尤为重要。
测试覆盖:luacov
luacov 统计每行 Lua 代码的执行情况,输出覆盖率报告:
busted --coverage
luacov # 生成 luacov.report.out
覆盖率报告的关注点:
- 分支是否都被覆盖(尤其错误分支、边界条件)。
- 新增代码是否落入未覆盖区域。
- 通过
.luacov配置排除非业务文件。
-- .luacov 配置文件
exclude = {
"spec",
"src/init.lua", -- 模块入口常是纯转发,可排除
}
include = {
"src",
}
覆盖率数值不是目的,而是「找盲区」的手段:覆盖率低的模块,往往是重构风险最高的模块,应优先补齐用例。
CI 集成:GitHub Actions
把 busted 接入 CI,才能让测试持续守护代码库。一个最小配置:
# .github/workflows/ci.yml
name: Lua CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
lua-version: ["5.1", "5.3", "5.4"]
steps:
- uses: actions/checkout@v4
- uses: leafo/gh-actions-lua@v10
with:
luaVersion: ${{ matrix.lua-version }}
- uses: leafo/gh-actions-luarocks@v4
- run: luarocks make --deps-mode=none
- run: luarocks install busted
- run: busted --coverage
矩阵测试多个 Lua 版本的价值在于捕获「版本差异」——这在 Lua 版本对比 中提过,5.1 与 5.4 的语义差异常被测试暴露出来。CI 失败即阻止合并,把问题挡在发布前。
测试组织与最佳实践
好的测试组织能让测试套件长期可维护:
project/
├── src/ # 业务代码
│ ├── calc.lua
│ └── user_repository.lua
├── spec/ # 测试代码
│ ├── calc_spec.lua
│ ├── user_repository_spec.lua
│ └── helpers/
│ └── mock_http.lua
├── .luacov # 覆盖率配置
└── .github/workflows/ci.yml
实践要点:
- 命名规范:
模块名_spec.lua,与源码一一对应,测试名用完整行为描述。 - 一个用例只验证一件事:用例失败时能立即定位到行为而非文件。
- 隔离外部依赖:HTTP、数据库、文件系统一律 stub/mock,避免测试依赖真实服务。
- 可重复性:测试不应依赖执行顺序,
before_each中重建被测对象。 - 把断言封装成语义:自定义断言让测试可读,失败信息可诊断。
- 配合类型注解:LuaLS 类型注解(见 现代 Lua 工具链)能提升测试代码的静态检查能力。
常见问题(FAQ)
busted 与老牌 luaunit 如何选择?
luaunit 更接近 xUnit 风格(断言类方法、TestCase),busted 则是 BDD 风格(describe/it)、支持异步、协程与 mock 更完善,且是 LuaRocks 官方推荐的测试框架。新项目建议直接选 busted,维护旧 luaunit 项目可继续用。
测试文件里 require 不到被测模块怎么办?
通常是 package.path 问题。用 --cwd 或配置 busted 的 lua 路径:busted 支持 --helper 加载辅助文件设置 package.path,或在 rockspec 的 test_dependencies 中声明依赖。确保被测模块能被 require 找到是测试可运行的前提。
如何测试带副作用的全局函数?
用 stub 替换全局:
local orig_print = print
stub(print, function(...) table.insert(captured, ...) end)
-- 执行被测代码
assert.same({"hello"}, captured)
stub(print) -- 还原
注意并发与顺序:before_each 中 stub,after_each 中 revert,避免用例间互相污染。
覆盖率报告提示未覆盖,但代码明明是热路径?
覆盖率「未覆盖」表示「测试没有走到那行」,并不代表代码有问题。优先为未覆盖的高风险分支(错误处理、边界值)补测试;对纯样板代码可在 .luacov 中排除。目标是让覆盖信号真实反映风险,而不是追求 100%。
busted 能在 LuaJIT 下跑吗?
可以。LuaJIT 是 Lua 5.1 兼容实现,busted 完全支持。配合 LuaJIT 跑测试还能顺便验证「可 JIT 编译」路径,避免生产环境才暴露的 NYI 问题。
相关阅读
- LuaRocks 发布与 CI:包结构、rockspec 与自动发布
- Lua 错误处理:pcall、xpcall 与 error 的最佳实践
- 现代 Lua 工具链:LuaLS 类型注解、Stylua 与工程化实践
- Lua 模块与包管理:require、package 与 LuaRocks
- Lua 协程深入解析:协作式多任务的实现
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。