Lua Busted 单元测试实战

目标

提供比较实用的 Lua Busted 单元测试实例。

环境

  • Unity 2018.2.5f1 Personal (64bit)
  • IntelliJ IDEA 2018.2.3 (Community Edition), Build #IC-182.4323.46, built on September 4, 2018
  • JRE: 1.8.0_152-release-1248-b8 amd64
  • JVM: OpenJDK 64-Bit Server VM by JetBrains s.r.o
  • Windows 10 10.0

目录结构及简单示例

1
2
3
└─project       项目文件夹
├─spec 测试代码(每个文件命名为 *_spec.lua 的形式)
└─src 项目代码

Busted 约定将测试放在 spec 文件夹中,命名为 *xx*_spec.lua。这样在 spec 父文件夹层级(即project)打开命令行使用 busted,会运行 spec 文件夹下的 *_spec.lua 中的所有测试。

spec 下新建文件如下:

1
2
3
4
5
6
7
8
9
-- spec/sample_spec.lua
describe("basic test", function()
it("should pass", function()
assert.truthy("yup")
end)
it("should throw error if assert false", function()
assert.falsy("yup")
end)
end)

project 下打开命令行,输入 bustedbusted -o TAP 回车,

运行结果:

1
2
3
4
5
6
7
D:\tmp\lua>busted -o TAP
ok 1 - basic test should pass
not ok 2 - basic test should throw error if assert false
# spec\sample_spec.lua @ 6
# Failure message: spec\sample_spec.lua:7: Expected to be falsy, but value was:
# (string) 'yup'
1..2

我们使用了 TAP 形式的输出,可以看到 busted 将 describe 与 it 连接起来形成了最后的测试名,因为我们的书写方式,最后的测试名是一个完整的句子,这方便通过测试名就对测试所关注的功能一目了然。

测试结果中第一个通过了,因为我们 assert.truty() 里面的值只要不是 falsenil 就能通过,字符串 "yup" 自然是可以通过的。

测试中第二个没有通过,因为我们在传入相同的字符串的时候,使用了 assert.falsy(),这当然会失败。从这个例子我们可以看到,当测试不通过时,busted 会给出错误所在行数,也会详细地告诉我们测试希望的值是什么、实际得到的值是什么。

概念说明与规范

在实际的单元测试中,项目代码往往有很多依赖,各种调用将各个模块连接耦合起来。在设计不佳的系统中甚至可能为了测试一个模块而初始化所有模块。UI 逻辑多了不好测。UI 需要加载其他的模块也不好独立出来测。

针对这种情况,使用 mock 可以解决一部分问题。不过,这在 MVC 分离不全情况下也难以应用。能测的主要是那种独立函数、独立类。涉及 UI 的测试还是要另想办法。

A mock object is simply a testing replacement for a
real-world object.

– Pragmatic Unit Testing in C# with NUnit 2e

Mock 就是一个真实模块的替代品。可以类比于演员的替身来理解。它与真实模块有相同的接口,但是接口返回值是我们直接指定的特定值,这样就达成了其他模块改变时我们测试的这个模块获得的返回值依然是固定的,也就达成了与其他模块隔离及解耦的目的。

通过 Mock 的使用,我们还可以监测对一个模块的调用行为,可以通过 Mock 来统计调用次数,也能知道调用时传入的参数。

Busted 的 Mock 主要提供了调用次数统计、调用时传入参数的获取的功能。但是没有起到让我们指定返回值的作用。在 NUnit 中的 Mock 通常是自行实现一个与真实模块具有相同接口(interface)的类,然后进行使用。在 NUnit 中,Mock 更多地是一种概念而不是实际接口;在 Busted 中则提供了实际的 mock 函数,但是仅仅是有一些监测功能。

但是核心问题在于,测试的函数可能依赖多个模块,要调用其他模块的函数、取其中的值。Lua busted 的 Mock 并不能满足需求,所以替代真实模块、隔离系统其他模块的任务,还是需要我们根据实际情况来手工解决。

以下先介绍 busted 提供的调用统计的方法,再介绍模块隔离/替代的办法,最后对 Busted 的测试命名规范给出一些建议。在下一小节将对介绍的内容给出综合示例。

调用统计

在 busted 中,进行参数和调用统计的 Mock 分为 spystub 两种。

  • spy 对目标进行监测,可以监测其是否被调用、被用什么函数调用。
  • stub 则是对目标进行包装,同样监测其是否被调用、被用什么函数调用。与 spy 不同之处是,stub 会截取调用,不会实际调用目标。适合测试数据层,这样不会实际找数据库要数据。
  • spy 和 stub 是单体操作,mock 是对整个表进行的操作,mock(t) 得到 t 的 spies, mock(t, true) 得到 t 的 stubs

模块隔离

主要解决全局变量、require 的隔离。

全局量

如果待测试的代码使用了全局变量 GLOBAL,那么使用 stub:

1
stub(_G, "GLOBAL")

require

使用 lua 的 package 机制来改变 require.

如果要导入 src/logic/sample_module.lua 这个包,即require("src/logic/sample_module"),则使用:

1
2
3
package.preload["src/logic/sample_module"] = function ()
--print("fake load module")
end

这样在 require 这一模块时,不会去加载实际的文件,而是运行这里定义函数。在这里面就可以提供我们自己的 Mock 了。

命名规范

笔者比较喜欢 describe it style (参照的是 https://www.bignerdranch.com/blog/why-do-javascript-test-frameworks-use-describe-and-beforeeach/), 因为最后的测试名是一个完整的句子,这方便通过测试名就对测试所关注的功能一目了然。

以下提供一些格式,| 分隔不同的段,最后一个段放在 it() 里面,前面的都放在 describe() 里面,最终组合成完整的句子:

1
2
3
Target | when | does sth / returns sth
Target | should have sth
Target | can do sth

以上的格式概括了常见的测试关注点:模块可以做什么、应该有什么、在某种情况下应该做什么或者返回什么。

Busted 支持给测试打标签,这样方便独立运行具有某些 tag 或者不具有某些 tag 的测试。

例如笔者定义了 #InternalVariableUsed 这个标签来标记使用了私有或者保护变量的测试,这种测试很容易因为重构而失败。毕竟一般的单元测试应该测公共的方法和接口。

使用方法:

  • 只测有该标签的测试:busted -o TAP --tags=InternalVariableUsed
  • 排除有该标签的测试:busted -o TAP --exclude-tags=InternalVariableUsed

另外,busted 采用的 assert 形式,期望的值写在前,实际值写在后,例如:

1
assert.are.equal(expected, actual)

更加复杂的实例

此处创建常规的模块 sample_module,提供全局变量的模块 sample_module_global,以及没有用到只是 require 的模块 sample_module_empty,还有 require 了其他模块的模块 main_module,分别写出对它们的测试方法。

针对 main_module,提供了隔离其他模块的单元测试,也提供了实际加载其他模块的集成测试。

各文件代码如下:

src/logic/main.lua

1
2
3
local main = require("main_module")

print(main:TimeSixValue(2))

src/logic/main_module.lua

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
local MainModule = {}

local SampleModule = require("sample_module")
require("sample_module_global")
local Empty = require("sample_module_empty")

function MainModule:TimeSixValue(value)
return SampleModule:ModifyInt(value) * SampleGlobal.ModifyInt(value) / value
end

function MainModule:ReturnGlobalString()
return GLOBAL_STRING
end

return MainModule

src/logic/sample_module.lua

1
2
3
4
5
6
local SampleModule = {}

function SampleModule:ModifyInt(value)
return value * 2
end
return SampleModule

src/logc/sample_module_empty.lua

1
2
3
local SampleModuleEmpty = {}

return SampleModuleEmpty

src/logic/sample_module_global.lua

1
2
3
4
5
6
7
8
9
10
11
SampleGlobal = {}

function SampleGlobal.TestFunc()
print("SampleGlobal.TestFunc called")
end

function SampleGlobal.ModifyInt(value)
return value * 3
end

GLOBAL_STRING = "a global string"

spec/main_module_spec.lua

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
describe("MainModule", function()
local mainModule = {}
-- By default each test file runs in a separate insulate block

-- run before the describe block.
setup(function ()
package.preload["sample_module"] = function ()
local mock = {}
function mock:ModifyInt(value)
return value * 2
end
return mock
end
package.preload["sample_module_global"] = function ()
SampleGlobal = {}
function SampleGlobal.ModifyInt(value)
return value * 3
end
_G.SampleGlobal = SampleGlobal

end
package.preload["sample_module_empty"] = function ()

end
mainModule = require("../src/logic/main_module")
end)

it("should make value six times of itself", function()
assert.are.equal(12, mainModule:TimeSixValue(2))
end)
it("should return global string", function ()
stub(_G, "GLOBAL_STRING")
--print(mainModule:ReturnGlobalString())
assert.are_not.equal("a global string", mainModule:ReturnGlobalString())
end)
end)

在这里使用 package.preload 给各个模块写了 Mock。对于常规的模块很方便。对于全局的略麻烦,用到了 _G。正常情况下能写 local return 式的模块时还是尽量不要写 global 的。对于 empty 的模块或者没使用的模块,给一个空函数就完全够用了。把全部变量封住就用 stub

spec/main_module_integrated_spec.lua

1
2
3
4
5
6
7
8
9
10
describe("MainModule", function()
local mainModule = {}
setup(function ()
package.path = "./src/logic/?.lua;"..package.path
mainModule = require("../src/logic/main_module")
end)
it("should make value six times of itself #Integrated", function()
assert.are.equal(12, mainModule:TimeSixValue(2))
end)
end)

做集成测试主要得注意 package.path,要把源码路径加好。

spec/sample_module_spec.lua

1
2
3
4
5
6
7
8
9
describe("SampleModule", function()
local sampleModule = {}
setup(function ()
sampleModule = require("../src/logic/sample_module")
end)
it("should double value", function()
assert.are.equal(4, sampleModule:ModifyInt(2))
end)
end)

spec/sample_module_global_spec.lua

1
2
3
4
5
6
7
8
describe("SampleGlobal", function()
setup(function ()
require("../src/logic/sample_module_global")
end)
it("should triple value", function()
assert.are.equal(6, SampleGlobal.ModifyInt(2))
end)
end)

运行结果

1
2
3
4
5
6
7
D:\tmp\lua>busted -o TAP
ok 1 - MainModule should make value six times of itself #Integrated
ok 2 - MainModule should make value six times of itself
ok 3 - MainModule should return global string
ok 4 - SampleGlobal should triple value
ok 5 - SampleModule should double value
1..5

复杂例子打包下载

针对 Lua 单元测试的思考

Lua 单元测试有一个特点就是想要使用私有成员变量时非常方便,但是这也导致测试更加容易失败。因为内部实现总是没有外部接口那么稳定的。需要建立一些原则来进行规范。

工作单元有三种最终结果:返回值、内部状态改变、调用第三方对象。基于值的测试验证了一个函数的返回值;基于状态的测试改变被测试对象的状态,然后验证其可见的状态变化;交互测试则是验证一个对象如何向其他对象发送消息的测试。实际单元测试中,优先考虑基于值、基于状态的测试方法,因为这两种测试可以减少对代码内部细节的假设。
来自:桃子妈咪 https://www.jianshu.com/p/c7d5a214c485

参考