Lua 测试工程化指南:busted 框架与 BDD 实践

全面掌握 Lua 测试工程化:busted 测试框架的 BDD 语法、luassert 断言库、stub/spy/mock 行为验证、luacov 覆盖率统计,以及 GitHub Actions CI 集成与可维护测试的组织实践。

为什么需要 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 问题。

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「lua」更多文章

  1. LuaRocks 发布与 CI:从 rockspec 到自动化分发
  2. Lua 与 AI/LLM:Agent 脚本、NPC 智能与动态内容
  3. Neovim 插件工程化:从架构到发布的完整指南