Lua で単体テストのフレームワークを作った話を書きます。自作の Lua スクリプトやバンドラのテストのためにテストフレームワークを探していたのですが、気にいるものがなかったため作成しました。
要件
-
Jest, Vitest ライクなインターフェースであること
- テストをグループ化できる
- グループは階層化できる
- 単体のファイルとして実行できること
- Lua には組み込みのテストコマンドがないため、テストファイルをそのまま実行することでテストを行うことができる
- 出力が見やすい
- 階層ごとに成否が表示される
- 色付きで表示される
- 失敗したテストのエラーが最後に列挙される
- アサーションのためのヘルパーがあり、グローバルを汚染しない
- 例えば
assert.equals
を登録しない
- 例えば
インターフェース
最終的なインターフェースはこのようになりました。describe
関数によりグループを作成し test
関数に渡したクロージャで実際にテストを実行します。設計によっては describe
と test
を同一の実装にすることも可能ですが (it
)、describe
内で発生した例外をテストとして捕捉しないために別の実装としています。
local describe = require("test").describe
local test = require("test").test
describe("main_test", function()
describe("sub_test", function()
test("test1", function()
assert(1 + 1 = 2, "1 + 1 must be 2.")
end)
end)
end)
ファイルを分割することも可能です。以下のように分割したとき sub_test.lua
は単体で実行することもできます。
local describe = require("test").describe
describe("main_test", function()
require("sub_test")
-- other tests
end)
local describe = require("test").describe
local test = require("test").test
describe("sub_test", function()
test("test1", function()
assert(1 + 1 = 2, "1 + 1 must be 2.")
end)
end)
require("test")
を複数回呼び出していますが、Lua では require
されたチャンクがキャッシュされるためパフォーマンスに影響はありません。
テストの定義
テストを記述する際には describe
関数でグループ化し、 test
関数でテストを定義することができました。テストを実行して結果をまとめて表示するには、グループ化されたテストを木構造で表現してテスト結果を格納する必要があります。まずはこの木構造を表現するために次のような型を用意します (分かりやすさのため TypeScript で表現したものを併記します)。
---@class Test
---@field name string
---@field func function
---@field result { success: boolean, error: string } | nil
---@class TestContext
---@field parent TestContext | nil
---@field name string
---@field children (Test | TestContext)[]
---@field result { success: boolean } | nil
type Test = {
name: string,
func: () => void,
result: { success: bool, error: string } | null,
};
type TestContext = {
parent: TestContext | null,
name: string,
children: (Test | TextContext)[],
result: { success: boolean },
};
次に describe
関数の呼び出しにより TestContext
のツリーを作成できるようにします。このときグローバルの _ENV.__testContext
を使用することで上位のコンテキストがあるかどうかを判別します。上位コンテキストがある場合はコンテキストの親子関係を設定し、グローバルのコンテキストを置き換えることでコンテキストを切り替えます。
---@param name string
---@param func fun()
local function describe(name, func)
-- コンテキストの定義
local ctx = {
parent = nil,
name = name,
children = {},
result = nil,
}
-- グローバルにコンテキストがある (discribe の中で呼び出されている) 場合、
-- コンテキストの親子関係を設定する
if _ENV.__testContext ~= nil then
table.insert(_ENV.__testContext.children, ctx)
ctx.parent = _ENV.__testContext
end
-- グローバルのコンテキストを現在のコンテキストに置き換える
_ENV.__testContext = ctx
func()
if _ENV.__testContext.parent == nil then
-- 親のコンテキストがない場合、テストを実行し結果を出力する
local succeeded = performTest(_ENV.__testContext, 0)
printTestResult(_ENV.__testContext, 0, {})
if not succeeded then
os.exit(1)
end
else
-- 親のコンテキストがある場合、グローバルのコンテキストを親に戻す
_ENV.__testContext = _ENV.__testContext.parent
end
end
test
関数では同様にテストコンテキストにテストを登録します。
---@param name string
---@param func fun()
local function test(name, func)
if _ENV.__testContext == nil then
-- 匿名のコンテキストを設定する
_ENV.__testContext = {
parent = nil,
name = "(anonymous)",
children = {},
result = nil,
}
end
-- グローバルのコンテキストにテストを追加する
table.insert(_ENV.__testContext.children, {
name = name,
func = func,
result = nil,
})
end
require
や load
(loadfile
) されたチャンクはグローバル (_ENV
) を継承するため、describe
内でサブグループを require
することが可能になります。サブグループを定義したファイルを直接実行するとサブグループを定義する describe
がトップレベルとなってテストが実行されます。
テストを実行する
テストを実行するにはツリーに対して再帰的にテストを実行していけばよいです。pcall
関数は投げられた例外を補足することができます。テストコンテキストの成否判定はすべての子コンテキスト (またはテスト) が成功していれば成功とみなすことができます。
---@param ctx TestContext | Test
---@param depth integer
---@return boolean succeeded
local function performTest(ctx, depth)
if ctx.func ~= nil then
--node is Test
local success, err = pcall(ctx.func)
ctx.result = {
success = success,
error = tostring(err),
}
else
--node is test context
for _, child in ipairs(ctx.children) do
ctx.result = { success = true }
if not performTest(child, depth + 1) then
ctx.result.success = false
end
end
end
return ctx.result.success
end
テスト結果の表示
階層表示
テスト結果の表示についても同様にツリーに対して再帰的に結果を表示すればよいです。適切にインデントを挿入することで階層表示をすることができます。テストの実行と結果の表示を分離することで親の階層に子の階層の成否を予め表示することができます。
test (2/2)✔
sub test (2/2)✔
1 + 2 to be 3 ✔
1 + 2 not to be 4 ✔
sub test (2/2)✔
sub test 2 (2/2)✔
1 - 1 to be 0 ✔
1 - 1 not to be 1 ✔
sub test 2 (2/2)✔
test (2/2)✔
色付き表示
色付きの表示を行うには ANSI エスケープコードを利用できます。NO_COLOR
環境変数を見ることで色なしの表示にも対応します。
local function chalkGreen(text)
if os.getenv("NO_COLOR") then
return text
else
return "\x1b[92m" .. text .. "\x1b[0m"
end
end
値の表示
Lua は値を文字列に変換するときに、テーブルなどはポインタが表示されてしまいます。アサーションで例外を作成する際にテーブルの内容が表示されると便利なためデバッグ用の表示を作成していきます。今回は文字列とテーブル (オブジェクト・配列) を特殊に扱います。
まず文字列はエスケープした後にダブルクオートで囲って表示します。エスケープは特定の値をエスケープシーケンスで置き換える他、空白以外の表示不可能文字を 16 進数で表示するようにします。
if type(value) == "string" then
return "\"" .. escapeString(value) .. "\""
end
---@param value string
---@return string
local function escapeString(value)
local result = value:gsub("\\", "\\\\")
:gsub("\a", "\\a")
:gsub("\b", "\\b")
:gsub("\f", "\\f")
:gsub("\n", "\\n")
:gsub("\r", "\\r")
:gsub("\t", "\\t")
:gsub("\v", "\\v")
:gsub("\"", "\\\"")
:gsub("[^%g%s]", function(c)
return string.format("\\x%02X", c:byte())
end)
return result
end
テーブルは配列部 (array part) とマップ部 (map part) に分けて表示します。それぞれの要素を文字列化し、,
で区切って表示します。
if type(value) == "table" then
local arrayPart = arrayPartToStrings(value)
local mapPart = mapPartToStrings(value)
local items = {}
table.move(arrayPart, 1, #arrayPart, #items + 1, items)
table.move(mapPart, 1, #mapPart, #items + 1, items)
if #items == 0 then
return "{}"
else
return "{ " .. table.concat(items, ", ") .. " }"
end
end
配列部は基本的に値を列挙することで表示します。Lua の配列 0 以下の値や小数値のインデックス、空要素 (holes in arrays) が存在するためこれに対応する必要があります。空要素は (empty x n)
の形で表示し、最初の値のインデックスが 1 から始まらない場合や前の要素とのインデックスの差が整数でない場合は [index] = value
で表示します。空要素が多い場合も [index] = value
の形で表示します。
実際には 0 以下の値や小数値のインデックスは array part ではなく map part に格納されますが、実用的には数値として順序付けできるものは順に表示するのが適切と考えました。
---@param value table
---@return string[]
local function arrayPartToStrings(value)
local indices = {}
for k, _ in pairs(value) do
if type(k) == "number" then
table.insert(indices, k)
end
end
table.sort(indices)
if #indices == 0 then
return {}
end
local result = {}
if indices[1] == 1 then
table.insert(result, toDebugString(value[indices[1]]))
else
table.insert(result, string.format("[%s] = %s", tostring(indices[1]), toDebugString(value[indices[1]])))
end
for i = 2, #indices do
local valueString = toDebugString(value[indices[i]])
local diff = indices[i] - indices[i - 1]
if diff == 1 then
table.insert(result, valueString)
elseif diff == math.floor(diff) and diff < 10 then
table.insert(result, string.format("(empty x %d)", diff - 1))
table.insert(result, valueString)
else
table.insert(result, string.format("[%s] = %s", tostring(indices[i]), valueString))
end
end
return result
end
マップ部は key = value
の形で表示します。
---@param value table
---@return string[]
local function mapPartToStrings(value)
local keys = {}
for k, _ in pairs(value) do
if type(k) == "string" then
table.insert(keys, k)
end
end
table.sort(keys)
local result = {}
for _, key in pairs(keys) do
table.insert(result, string.format("%s = %s", key, toDebugString(value[key])))
end
return result
end
アサーションのヘルパー
多くのテストライブラリではアサーションのためのヘルパーが存在しています。Lua には標準で assert
関数があり、第一引数が true
であることを表明します。これに加えオブジェクトの等価性などを判定できるヘルパーを追加していきます。
ヘルパーの追加は主に次のような方法が考えられました。
-
assertEquals(foo, bar)
のような関数群を追加する- 利点: Lua の標準の書き方に近い, グローバルを汚染しない
- 欠点: Lua にはテーブルの一部をローカル変数に導入する記法がないため、インポートが煩雑
-
assert.equals(foo, bar)
のようにassert
以下に関数群を追加する- 利点:
assert
以下にすべて導入されるため一括でインポートできる - 欠点: 型補完を受けようと思うとグローバルの
assert
を汚染せざるを得ない
- 利点:
-
expect(foo):equals(bar)
のように値を一度ラップする- 利点:
expect
だけをインポートすれば良い, グローバルを汚染しない - 欠点: Lua の標準の書き方から遠い, 本来必要のないクラスを作っている
- 利点:
今回はインポートの用意でありとグローバル汚染を避けられる expect(foo):equals(bar)
のようなインターフェースを用意することにしました。この方法では expect
関数で Expectation
クラスを作成し、equals
や toBe
のようなメソッドを呼び出します。Lua はプロトタイプベースのオブジェクト指向言語のため、プロトタイプ上に Expectation
クラスを実装します。次の手順で実装することができます。
- メソッドを定義するテーブル
expect
を作成する -
expect
内にメソッドを実装する (expect.toBe
,expect.equals
など) -
expect
のメタテーブルに__call
を設定し、関数呼び出しに対応させる (コンストラクタ)- 関数呼び出し内では、
value
に値を持つ新しいテーブルobj
を作る -
obj
のメタテーブルの__index
にexpect
を設定することでメソッドを呼び出せるようにする -
obj
を返す
- 関数呼び出し内では、
以下の例は expect(foo).not:equal(bar)
のように not
をサポートするために多少複雑になっています。
---@class Expectation
---@field value any
---@field negated boolean
---@field not_ Expectation
---@overload fun(value: any): Expectation
local expect = {}
---Expects `==` equality.
---@param expected any
function expect.toBe(self, expected)
-- `not` つきで呼び出されたかどうかで結果を変えられるヘルパー
self:assert(
self.value == expected,
string.format(
"expect(received):toBe(expected)\nExpected: %s\nReceived: %s",
toDebugString(expected),
toDebugString(self.value)
),
string.format(
"expect(received).not_:toBe(expected)\nExpected not: %s\nReceived: %s",
toDebugString(expected),
toDebugString(self.value)
)
)
end
setmetatable(expect --[[@as table]], {
__call = function(_, value)
local obj = { value = value, negated = false }
local notObj = { negated = true }
obj.not_, notObj.not_ = notObj, obj
setmetatable(obj, { __index = expect })
setmetatable(notObj, { __index = obj })
return obj
end
})
JavaScript でクラスを使わずにクラスと同等のものを実装する際と概ね同じ方法です。expect
自体が呼び出し可能である (JavaScript の場合は 関数.prototype
にメソッドを定義する) 点が異なります。
以降では追加したヘルパーのいくつかを取り上げます。
toEqual
値が同じかを表明するおなじみのやつです。toBe
(==
) とは異なり実体ではなく値の等価性を判定します。値 $a, b$ の同値性はそれぞれの型 $A, B$ の型の包含関係を調べることで判定できます (これは構造的片付けの意味での型です)。例えば $A \supset B$ であるかを調べる関数を定義し、$A \supset B$ かつ $B \supset A$ であれば $A = B$ であると判定しています。
今回はテーブルについて特別扱いし、$B$ のすべてのフィールド $x$ に対して $A[x] \supset B[x]$ であれば (すべてのフィールドを持ちその値が super set であれば) 包含していると判定します。
---@param sub any
---@param sup any
---@return boolean
local function isSubtypeOfOrEqualsTo(sub, sup)
if sub == sup then
return true
elseif type(sub) ~= type(sup) then
return false
end
if type(sub) ~= "table" then
return sub == sup
end
for key, value in pairs(sub) do
if not isSubtypeOfOrEqualsTo(value, sup[key]) then
return false
end
end
return true
end
---@param a any
---@param b any
---@return boolean
local function equals(a, b)
return isSubtypeOfOrEqualsTo(a, b) and isSubtypeOfOrEqualsTo(b, a)
end
---@param self Expectation
---@param another any
function expect.toEqual(self, another)
-- self が expectation であることを表明
assertExpectation(self)
self:assert(
equals(self.value, another),
string.format(
"expect(received):toEqual(item)\nExpected: %s\nReceived: %s",
toDebugString(another),
toDebugString(self.value)
),
string.format(
"expect(received).not_:toEqual(item)\nExpected: not %s\nReceived: %s",
toDebugString(another),
toDebugString(self.value)
)
)
end
toContain
テーブルが特定の値を持つかを判定します。Lua には関数型に見られるようなインターフェースがないため愚直に for
を回す必要があります。
---Expects table (array) to contain a item as `==` equality.
---@param item any
function expect.toContain(self, item)
-- self が expectation であることを表明
assertExpectation(self)
local pass = false
if type(self.value) == "table" then
for _, v in ipairs(self.value) do
if v == item then
pass = true
break
end
end
end
self:assert(
pass,
string.format(
"expect(received):toContain(item)\nReceived: %s",
toDebugString(self.value)
),
string.format(
"expect(received).not_:toContain(item)\nReceived: %s",
toDebugString(self.value)
)
)
end
assertExpectation
これは Expect
型のメソッドではないですが、自身が Expectation
であることを表明するものです。メソッド呼び出しの :
を .
に書き間違えることが多いため実装されました。
-- この 2 つは同じ
expect(foo):toBe(bar)
toBe(expect(foo), bar)
-- この 2 つは同じ (. に書き間違えている)
-- `bar != nil` だよ!と言われるが、気づきにくくて謎
expect(foo).toBe(bar)
toBe(bar)
これはオブジェクトがテーブルであり、メタテーブルを遡ったときに __index
に expectation
が設定されているかで判定しています。
---@param value any
---@return boolean
local function isExpectation(value)
if type(value) ~= "table" then
return false
end
local meta = getmetatable(value)
while meta ~= nil do
if meta.__index == expect then
return true
end
meta = getmetatable(meta.__index)
end
return false
end