1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

テストフレームワークを作った話

Last updated at Posted at 2025-02-28

Lua で単体テストのフレームワークを作った話を書きます。自作の Lua スクリプトやバンドラのテストのためにテストフレームワークを探していたのですが、気にいるものがなかったため作成しました。

要件

  • Jest, Vitest ライクなインターフェースであること
    • テストをグループ化できる
    • グループは階層化できる
  • 単体のファイルとして実行できること
    • Lua には組み込みのテストコマンドがないため、テストファイルをそのまま実行することでテストを行うことができる
  • 出力が見やすい
    • 階層ごとに成否が表示される
    • 色付きで表示される
    • 失敗したテストのエラーが最後に列挙される
  • アサーションのためのヘルパーがあり、グローバルを汚染しない
    • 例えば assert.equals を登録しない

インターフェース

最終的なインターフェースはこのようになりました。describe 関数によりグループを作成し test 関数に渡したクロージャで実際にテストを実行します。設計によっては describetest を同一の実装にすることも可能ですが (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 は単体で実行することもできます。

main_test.lua
local describe = require("test").describe

describe("main_test", function()
    require("sub_test")
    -- other tests
end)
sub_test.lua
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

requireload (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 クラスを作成し、equalstoBe のようなメソッドを呼び出します。Lua はプロトタイプベースのオブジェクト指向言語のため、プロトタイプ上に Expectation クラスを実装します。次の手順で実装することができます。

  1. メソッドを定義するテーブル expect を作成する
  2. expect 内にメソッドを実装する (expect.toBe, expect.equals など)
  3. expect のメタテーブルに __call を設定し、関数呼び出しに対応させる (コンストラクタ)
    1. 関数呼び出し内では、value に値を持つ新しいテーブル obj を作る
    2. obj のメタテーブルの __indexexpect を設定することでメソッドを呼び出せるようにする
    3. 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)

これはオブジェクトがテーブルであり、メタテーブルを遡ったときに __indexexpectation が設定されているかで判定しています。

---@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
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?