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

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

https://github.com/Tsukina-7mochi/lua-testing-library/

## 要件

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

## インターフェース

最終的なインターフェースはこのようになりました。`describe` 関数によりグループを作成し `test` 関数に渡したクロージャで実際にテストを実行します。設計によっては `describe` と `test` を同一の実装にすることも可能ですが (`it`)、`describe` 内で発生した例外をテストとして捕捉しないために別の実装としています。

```lua
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` は単体で実行することもできます。

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

describe("main_test", function()
    require("sub_test")
    -- other tests
end)
```

```lua: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)
```

:::note
`require("test")` を複数回呼び出していますが、Lua では `require` されたチャンクがキャッシュされるためパフォーマンスに影響はありません。
:::

## テストの定義

テストを記述する際には `describe` 関数でグループ化し、 `test` 関数でテストを定義することができました。テストを実行して結果をまとめて表示するには、グループ化されたテストを木構造で表現してテスト結果を格納する必要があります。まずはこの木構造を表現するために次のような型を用意します (分かりやすさのため TypeScript で表現したものを併記します)。

```lua
---@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
```

```typescript
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` を使用することで上位のコンテキストがあるかどうかを判別します。上位コンテキストがある場合はコンテキストの親子関係を設定し、グローバルのコンテキストを置き換えることでコンテキストを切り替えます。

```lua
---@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` 関数では同様にテストコンテキストにテストを登録します。

```lua
---@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` 関数は投げられた例外を補足することができます。テストコンテキストの成否判定はすべての子コンテキスト (またはテスト) が成功していれば成功とみなすことができます。

```lua
---@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` 環境変数を見ることで色なしの表示にも対応します。

```lua
local function chalkGreen(text)
    if os.getenv("NO_COLOR") then
        return text
    else
        return "\x1b[92m" .. text .. "\x1b[0m"
    end
end
```

### 値の表示

Lua は値を文字列に変換するときに、テーブルなどはポインタが表示されてしまいます。アサーションで例外を作成する際にテーブルの内容が表示されると便利なためデバッグ用の表示を作成していきます。今回は文字列とテーブル (オブジェクト・配列) を特殊に扱います。

まず文字列はエスケープした後にダブルクオートで囲って表示します。エスケープは特定の値をエスケープシーケンスで置き換える他、空白以外の表示不可能文字を 16 進数で表示するようにします。

```lua
if type(value) == "string" then
    return "\"" .. escapeString(value) .. "\""
end
```

```lua
---@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) に分けて表示します。それぞれの要素を文字列化し、`,` で区切って表示します。

```lua
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` の形で表示します。

:::note
実際には 0 以下の値や小数値のインデックスは array part ではなく map part に格納されますが、実用的には数値として順序付けできるものは順に表示するのが適切と考えました。
:::

```lua
---@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` の形で表示します。

```lua
---@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` クラスを実装します。次の手順で実装することができます。

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

以下の例は `expect(foo).not:equal(bar)` のように `not` をサポートするために多少複雑になっています。

```lua
---@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
})
```

:::note
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 であれば) 包含していると判定します。

```lua
---@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` を回す必要があります。

```lua
---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` であることを表明するものです。メソッド呼び出しの `:` を `.` に書き間違えることが多いため実装されました。

```lua
-- この 2 つは同じ
expect(foo):toBe(bar)
toBe(expect(foo), bar)

-- この 2 つは同じ (. に書き間違えている)
-- `bar != nil` だよ！と言われるが、気づきにくくて謎
expect(foo).toBe(bar)
toBe(bar)
```

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

```lua
---@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
```