Defold 测试与持续集成

系统讲解 Defold 项目的质量保障体系:测试策略与可测性设计、纯 Lua 逻辑与引擎解耦、busted 单元测试与用例编写、headless 集成测试、bob.jar 命令行构建、多平台构建矩阵、自动化版本号与发布,以及静态检查与代码规范。

引言

Defold 项目想上 CI,难点不在写 YAML,而在两件事:一是代码可测性——逻辑和引擎耦合在一起就测不了;二是构建可重复——编辑器点一下能出包,命令行也要能出同样的包。本文从测试策略讲起,覆盖纯 Lua 逻辑解耦、busted 单元测试、headless 集成测试、bob.jar 命令行构建、多平台构建矩阵、自动化版本号与发布,最后给出静态检查与规范落地方案。

前置阅读:资源管线与项目结构、Lua 模块与脚本生命周期。


目录


1. 测试策略与可测性设计

1. 测试金字塔

层级范围工具占比
单元测试纯函数、算法、状态机busted70%
集成测试系统协作、消息流headless 引擎20%
端到端真机点击流程手动 + 自动化脚本10%

Defold 项目最容易做的是第一层:把所有不依赖引擎的逻辑抽成纯 Lua 模块,这样就能在普通 Lua 解释器里跑测试,不需要启动引擎。

2. 什么该测

值得测:
- 伤害计算公式、掉落概率表
- 背包增删改查与容量规则
- 存档序列化与版本迁移
- 状态机流转(idle → run → jump)
- 数值配置的合法性(如概率之和为 1)

不值得测:
- 引擎 API 本身(go.set_position 等)
- 纯展示逻辑(UI 摆放)
- 一次性工具脚本

3. 可测性设计的三个原则

1. 依赖注入:模块不 require 引擎全局,需要什么从参数传进来
2. 无副作用:纯函数输入输出明确,不读写全局状态
3. 时间可注入:把 os.time() 当作参数传入,方便测试时间相关逻辑

踩坑:Defold 的 go、msg、vmath、sys 是引擎注入的全局,在普通 Lua 解释器里不存在。模块只要引用了它们,测试就会报 attempt to index a nil value。这就是必须解耦的原因。


2. 纯 Lua 逻辑与引擎解耦

1. 反例与正例

-- 不推荐:逻辑与引擎混在一起,无法单元测试
function on_message(self, message_id, message, sender)
    if message_id == hash("damage") then
        self.hp = self.hp - message.amount
        if self.hp <= 0 then
            go.delete()                             -- 依赖引擎
            msg.post("/score", "add", { n = 100 })  -- 依赖引擎
        end
    end
end
-- modules/combat.lua  纯 Lua,无引擎依赖
local M = {}

-- 返回:新血量、是否死亡、是否触发击退
function M.apply_damage(hp, amount, armor)
    local real = math.max(1, amount - (armor or 0))
    local new_hp = hp - real
    return new_hp, new_hp <= 0, real >= 10
end

function M.apply_heal(hp, max_hp, amount)
    return math.min(max_hp, hp + amount)
end

return M
-- 脚本里只做胶水层
local combat = require("modules.combat")

function on_message(self, message_id, message, sender)
    if message_id == hash("damage") then
        local hp, dead, knockback = combat.apply_damage(self.hp, message.amount, self.armor)
        self.hp = hp
        if knockback then
            go.set_position(go.get_position() + vmath.vector3(0, 10, 0))
        end
        if dead then
            go.delete()
            msg.post("/score", "add", { n = 100 })
        end
    end
end

收益:combat.lua 现在可以在任何 Lua 解释器里测试,包括 CI 机器。

2. 用适配层注入引擎能力

如果逻辑确实需要读时间、读配置,用适配层注入:

-- modules/session.lua
local M = {}

-- clock 默认用 os.time,测试时可传入假时钟
function M.new(clock)
    return { clock = clock or os.time, start = nil }
end

function M.begin(s)
    s.start = s.clock()
end

function M.elapsed(s)
    if not s.start then return 0 end
    return s.clock() - s.start
end

return M
-- 测试时传一个可控时钟
local fake_time = 1000
local session = session_mod.new(function() return fake_time end)
session_mod.begin(session)
fake_time = 1042
assert(session_mod.elapsed(session) == 42)

3. 单元测试框架与用例编写

1. 安装 busted 与目录结构

luarocks install busted
modules/
  combat.lua
  inventory.lua
tests/
  spec/
    combat_spec.lua
  .busted          ← 配置:告诉 busted 到哪里找模块
-- tests/.busted
return {
  default = {
    ROOT = { "tests/spec" },
    lpath = "./modules/?.lua;./?.lua",
    pattern = "_spec",
  },
}

2. 编写用例

-- tests/spec/combat_spec.lua
local combat = require("combat")

describe("combat.apply_damage", function()
    it("普通伤害扣血", function()
        local hp, dead = combat.apply_damage(100, 20, 0)
        assert.are.equal(80, hp)
        assert.is_false(dead)
    end)

    it("护甲减伤至少扣 1 点", function()
        local hp = combat.apply_damage(100, 5, 999)
        assert.are.equal(99, hp)
    end)

    it("血量归零判定死亡", function()
        local hp, dead = combat.apply_damage(10, 50, 0)
        assert.is_true(dead)
        assert.is_true(hp <= 0)
    end)

    it("高伤害触发击退", function()
        local _, _, knockback = combat.apply_damage(100, 30, 0)
        assert.is_true(knockback)
    end)
end)

describe("combat.apply_heal", function()
    it("治疗不超过上限", function()
        assert.are.equal(100, combat.apply_heal(80, 100, 50))
    end)
end)

3. 运行与覆盖率

busted tests/spec/combat_spec.lua
# ●●●●●●
# 5 successes / 0 failures / 0 errors / 0 pending

luarocks install luacov
busted --coverage
luacov
# modules/combat.lua    100.00%
# modules/inventory.lua  87.50%

踩坑:busted 与 Defold 的 Lua 版本(LuaJIT 2.1 / Lua 5.1)可能有语法差异。如果模块里用了 goto、整数除法 // 等 5.2+ 语法,busted 用 5.4 能跑但引擎会报错。建议用 lua5.1 或 luajit 作为 busted 的运行时。


4. 集成测试与无头运行

1. 用 bob 构建 headless 变体

Defold 提供无头(headless)构建:引擎不带图形后端,只跑逻辑与消息系统,适合在 CI 里验证「游戏能启动、脚本不报错、关键流程能走通」。

java -jar bob.jar \
  --root . \
  --archive \
  --platform x86_64-linux \
  --variant headless \
  --bundle-output build/headless \
  resolve build

说明:--variant headless 让引擎跳过渲染初始化,因此可以在没有 GPU 的 CI 容器里运行。

2. 运行时注入测试入口

通过 --config 覆盖启动集合,让引擎跑测试场景而不是主场景:

./build/headless/dmengine \
  --config=bootstrap.main_collection=/tests/test_main.collection \
  --config=display.width=320 \
  --config=display.height=240

3. 测试脚本自动退出

-- tests/test_runner.script
local passed, failed = 0, 0

local function check(name, cond)
    if cond then
        passed = passed + 1
        print("[PASS]", name)
    else
        failed = failed + 1
        print("[FAIL]", name)
    end
end

function init(self)
    check("world 初始化", world.init() == true)
    check("玩家生成", world.spawn_player() ~= nil)
    self.frames = 0
end

function update(self, dt)
    self.frames = self.frames + 1
    world.step(dt)
    if self.frames >= 120 then
        check("玩家未越界", world.player_in_bounds())
        print(string.format("[RESULT] passed=%d failed=%d", passed, failed))
        -- 用退出码反馈给 CI
        sys.exit(failed == 0 and 0 or 1)
    end
end

关键:sys.exit(code) 会把退出码传给 shell,CI 据此判断成败。没有这一步,CI 永远是绿的。另外记得用 timeout 120 ./dmengine ... 兜底,退出码 124 表示超时。


5. 用 bob.jar 做命令行构建

1. 下载与最简命令

# 官方构建服务(推荐,跟随稳定版)
curl -O https://d.defold.com/stable/bob/bob.jar

java -jar bob.jar \
  --root . \
  --archive \
  --platform x86_64-macos \
  --variant release \
  --bundle-output build/macos \
  resolve build bundle

bob 的三个阶段:

阶段作用
resolve下载并解析项目依赖(库、扩展)
build编译资源与代码,产出归档
bundle打包成目标平台可执行产物

2. 常用参数

参数说明示例
–root项目根目录.
–archive产出归档文件无值
–platform目标平台x86_64-win32
–variantdebug 或 releaserelease
–bundle-output产物输出目录build/win
–bundle-format产物格式zip、apk、html5
–settings覆盖 game.projectci.settings
–defines应用清单manifest.txt
–build-server构建服务器默认官方
–texture-compression纹理压缩开关无值

3. 用 settings 文件覆盖配置

CI 里常常要改版本号、包名,不必手改 game.project:

# ci.settings
[project]
version = 1.4.2

[bootstrap]
main_collection = /main/main.collection
java -jar bob.jar --settings ci.settings --archive --platform js-web --variant release resolve build bundle

踩坑:bob 会在 build/ 下缓存已编译资源。如果资源依赖变了但缓存没失效,会出现「改了没生效」。CI 上每次都是干净环境所以没问题,本地调试时记得 java -jar bob.jar --root . clean。


6. 多平台构建矩阵

1. 目标平台标识

平台标识产物格式
macOS Intelx86_64-macoszip
macOS Apple Siliconarm64-macoszip
Windowsx86_64-win32zip
Linuxx86_64-linuxzip
Androidarmv7-android / arm64-androidapk
iOSarm64-iosipa
HTML5js-web / wasm-webhtml5

2. CI 矩阵配置

name: Build
on:
  push:
    tags: ["v*"]

jobs:
  build:
    strategy:
      matrix:
        include:
          - platform: x86_64-win32
            format: zip
          - platform: x86_64-linux
            format: zip
          - platform: js-web
            format: html5
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "17"

      - name: Download bob
        run: curl -fO https://d.defold.com/stable/bob/bob.jar

      - name: Build
        run: |
          java -jar bob.jar \
            --root . \
            --archive \
            --platform ${{ matrix.platform }} \
            --variant release \
            --bundle-format ${{ matrix.format }} \
            --bundle-output build/${{ matrix.platform }} \
            resolve build bundle

      - uses: actions/upload-artifact@v4
        with:
          name: build-${{ matrix.platform }}
          path: build/${{ matrix.platform }}

移动端额外参数:Android 与 iOS 构建需要签名证书与密钥库,不要把密钥写进仓库,用 CI 的 Secrets 注入。

java -jar bob.jar --archive --platform arm64-android --variant release \
  --bundle-format apk --bundle-output build/android \
  --keystore release.keystore --keystore-pass "$KEYSTORE_PASS" \
  --keystore-alias release --keystore-alias-pass "$ALIAS_PASS" \
  resolve build bundle

7. 自动化版本号与发布

1. 版本号注入

用 git tag 或 CI 变量生成版本号,写进 settings:

VERSION="${GITHUB_REF_NAME#v}"     # v1.4.2 → 1.4.2
cat > ci.settings <<EOF
[project]
version = $VERSION
EOF
语义化版本:MAJOR.MINOR.PATCH
1.4.2
│ │ └── 修复(兼容)
│ └──── 新功能(兼容)
└────── 不兼容变更
预发布:1.5.0-rc.1

2. 发布流水线与产物校验

      - name: Package
        run: |
          cd build/x86_64-win32
          zip -r game-${{ github.ref_name }}.zip .

      - name: Release
        uses: softprops/action-gh-release@v2
        with:
          files: build/x86_64-win32/game-${{ github.ref_name }}.zip
          generate_release_notes: true

发布前至少校验三件事:

1. 包内不含调试符号与测试资源
2. 版本号与 git tag 一致
3. 关键资源存在(如 main.collection、启动脚本)
# 检查产物中的版本号
unzip -p build/game.zip game.project | grep -A1 "\[project\]"

8. 静态检查与代码规范

1. luacheck 与 selene

luarocks install luacheck
luacheck modules/ --globals go msg vmath sys gui factory --no-max-line-length

说明:--globals 声明 Defold 注入的全局,否则 luacheck 会把它们报成未定义变量。selene 速度更快,配置更清晰:

# selene.toml
std = "lua51"

[lints]
undefined_variable = "warn"
shadowing = "warn"

2. 代码规范

项目规范
缩进4 空格,不用 Tab
命名模块与变量 snake_case,常量 UPPER_CASE
行宽不超过 120 字符
函数长度不超过 50 行
文件长度不超过 500 行
require 顺序标准库 → 第三方 → 项目内
禁止全局变量、os.execute、深层嵌套

3. 提交前钩子与格式统一

# .git/hooks/pre-commit
#!/bin/sh
set -e

echo "== luacheck =="
luacheck modules/ --globals go msg vmath sys gui factory

echo "== busted =="
busted

echo "== 检查通过 =="
# 用 stylua 统一格式,消除不同编辑器配置带来的 diff 噪音
stylua --indent-type Spaces --indent-width 4 modules/

踩坑:CI 里跑 luacheck 要固定版本(如 luacheck==1.2.0),否则规则更新会让历史代码突然报错,把发布流程卡死。


9. 速查表

需求命令或做法备注
安装测试框架luarocks install busted建议用 lua5.1/luajit 运行时
跑单元测试busted配置放 tests/.busted
覆盖率busted --coverage && luacov输出各文件覆盖百分比
命令行构建java -jar bob.jar --archive --platform P --variant V resolve build bundle三阶段
覆盖配置--settings ci.settings改版本号、包名
无头测试--variant headless + dmengine --config=...无需 GPU
退出码sys.exit(code)否则 CI 永远绿
超时保护timeout 120 ./dmengine ...退出码 124 表示超时
静态检查luacheck modules/ --globals go msg vmath sys gui factory声明引擎全局
格式统一stylua --indent-width 4消除 diff 噪音
产物校验unzip -p pkg.zip game.project核对版本号

一句话记忆:把不依赖引擎的逻辑抽成纯 Lua 模块用 busted 测;集成测试用 bob 构建 headless 变体并在测试脚本末尾 sys.exit(失败数) 把结果传给 CI;发布用 bob.jar --settings ci.settings resolve build bundle 三阶段命令配合平台矩阵;提交前跑 luacheck + busted 两道关。


相关阅读

延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「defold」更多文章

  1. Defold 本地化与多语言
  2. Defold 团队协作与版本控制
  3. Defold 分析与崩溃上报