🔬

vitest 風のテストフレームワーク

に公開

FUnitvitest に強く影響を受けた C# 向け単体テストフレームワークです。.NET 10 を前提としている部分が多いですが、Unity エディター/ランタイムでも使えます。

基本構造

.NET 10 の単一ファイル実行とトップレベルステートメントに最適化されているので、.csproj 無しで構造化された単体テストを記述できます。

#:project ../src
#:package FUnit@*
#:package 追加のアサーションライブラリ等

// FUnit.Run は失敗したテストの数を返すのでそのまま終了コードとして使う。
return FUnit.Run(args, describe =>
{
    describe("テスト対象", it =>
    {
        it("テスト", () =>
        {
            // ココにテストを書く
        });

        it("非同期テスト", async () =>
        {
            // 非同期のテストも可能
        });

        it("その他のテスト1", ...);
        it("その他のテスト2", ...);
    });

    describe("別のテスト対象", ...);
    describe(...);
});

#:file ./subject.cs で別のファイルをプロジェクトに取り込む機能が欲しい。。。

👇

https://zenn.dev/sator_imaging/articles/b5daf46cee7698

テストの実行方法

.NET 10 の単体ファイルアプリとして実行します。

dotnet run ./tests/funit-test.cs

カレントディレクトリ以下の *test*.cs を一括で実行する dnx ツールもあります。

dnx -y FUnit.Run

🐙 GitHub アクションに対応

テストの実行結果を >> $GITHUB_STEP_SUMMARY で見やすい形で出力する機能もあります。

# -md または --markdown
dnx -y FUnit.Run -- -md >> $GITHUB_STEP_SUMMARY

👇 実行結果(想定エラーと describe/it 外のエラーを補足するテストなのでパスしてます)

ログの保管期限が切れたとき用

https://github.com/sator-imaging/FUnit/actions/runs/17765163878

Must アサーション

FUnit は AI エージェントにテストを書かせる為に作った面もあります。

なので、同梱のアサーションライブラリは BeEqual でコレクション(文字列以外の IEnumerable)を検証しようとした場合にエラーを投げる等、AI が雑なテストを書き辛くなっています。また、AI が出力したテストにごちゃごちゃ書いてあると邪魔になるので、検証方法は厳選したモノのみになっています。

// Value assertion
// ❌ BeEqual rejects collection type to prevent ambiguous comparisons
Must.BeEqual(expected, actual);
Must.BeSameReference(expected, actual);

// Collection assertion
Must.HaveSameSequence(expected, actual);
Must.HaveSameUnorderedElements(expected, actual);

// Text assertion
Must.ContainText(text, substring);
Must.NotContainText(text, substring);

// Exception assertion
Must.Throw<T>("Expected error message", () => Test());

// Conditional assertion
Must.BeTrue(list == list);
Must.BeFalse(list.Count < 0);

// ❌ Avoid asserting high-level conditions on collections
// ex Instead of checking if a value is absent, assert the full expected content
Must.BeFalse(list.Contains(x));  // ✅ Prefer: Must.HaveSameSequence(expectedList, actualList)

Must アサーションライブラリ自体のテストは、

@README.md に従って Must のテストを実装して

で生成したんですが、(ほぼ vitest と同じとは言え)学習ソースが README.md しかない状態でも問題なく実装できました。やっぱりアサーションは単純なメソッドに限りますね!

https://github.com/sator-imaging/FUnit/blob/main/tests/Must_test.cs

Unity で使う

FUnit.Run は単一ファイル実行限定のフレームワークではないので、Unity でも普通に使えます。なんならビルドしたアプリでも動きます。

アプリのデバッグ UI で設定を調整してから「テスト実行」ボタンを押すたびに結果が出る、みたいなことも可能(なハズ)です。

ただ、非同期テストと並列実行に対応している関係でテストが常にスレッドプールで実行されるので、await Awaitable.MainThreaAsync で実行スレッドを調整する必要があります。

※ メインスレッドで実行するテストが含まれている場合は、FUnit.Run を Task.Run 等で別スレッドに逃がす必要があります。(多分)

--concurrency 1(4秒) --concurrency 8(1秒で終わる)

ちなみに「テストの並列実行出来るなー」程度の感覚で実装したので、非同期処理が複雑に絡む場合等々のちゃんとした並列実行のテストはしていません。

ビルドが通る MonoBehaviour(Unity 2021)
using System;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;

public class FUnit_Unity : MonoBehaviour
{
    [Range(1, 10)]
    [SerializeField] int concurrency = 10;
    [SerializeField] Text consoleLogText;

    // 複数スレッドからの同時書き込みに対応
    readonly object sync = new object();
    string consoleLog = string.Empty;
    void Update() => consoleLogText.text = consoleLog;

    async void OnEnable()
    {
        Application.logMessageReceivedThreaded += (message, _, _) => { lock (sync) { consoleLog = $"{consoleLog}\n{message.Trim()}"; } };

        await Task.Delay(500);  // warmup

        var result = FUnit.Run(new string[] { "--concurrency", concurrency.ToString() }, describe =>
        {
            describe("Unity Integration", it =>
            {
                // Unity だと「ラムダ式はデリゲート型では…」とか出て暗黙的にキャストされないので、
                // Action のコンストラクターを使う必要がある。
                it("should be run (Action)", new Action(() =>
                {
                    Debug.LogWarning($"Starting...: Action (thread:{Environment.CurrentManagedThreadId})");
                    Must.BeEqual(true, true);
                    Debug.LogWarning($"Done: Action (thread:{Environment.CurrentManagedThreadId})");
                }));

                // 同上(非同期は Task と ValueTask に対応)
                it("should be run (Func<Task>))", new Func<Task>(async () =>
                {
                    Debug.LogWarning($"Starting...: Func<Task> (thread:{Environment.CurrentManagedThreadId})");
                    await Task.Delay(1000);
                    Debug.LogWarning($"Done: Func<Task> (thread:{Environment.CurrentManagedThreadId})");
                }));

                // 並列実行の確認
                it("should be run simultaneously (Foo)", new Func<ValueTask>(() => concurrent("Foo")));
                it("should be run simultaneously (Bar)", new Func<ValueTask>(() => concurrent("Bar")));
                it("should be run simultaneously (Baz)", new Func<ValueTask>(() => concurrent("Baz")));

                async static ValueTask concurrent(string label)
                {
                    Debug.LogWarning($"Starting...: {label} (thread:{Environment.CurrentManagedThreadId})");
                    await Task.Delay(1000);
                    Debug.LogWarning($"Done: {label} (thread:{Environment.CurrentManagedThreadId})");
                }

                it("should throw", new Action(() => throw new Exception("Panic!!")));
            });
        });

        Debug.Log("<b></b>");
        Debug.Log(FUnit.Result.ToString("<color=lime>", "<color=orange>", "</color>"));
    }
}

FUnit の仕組み

作っている時は何とも思わなかったんですが、作り終わって動いているのを見るとコレなんで動いてるんだ? って感じですw

FUnit.Run 呼び出し側と呼ばれる Run 内部の対応を見てみると、

// FUnit.Run 内部

// builder は絵文字で表すと Action<🟢>
builder.Invoke( /*🟢*/ (subject, /*🟣*/ it) =>
{
    if (!testCasesBySubject.TryGetValue(subject, out var testCases))
    {
        testCases = [];
        testCasesBySubject[subject] = testCases;
    }

    // 🟣 受け取った関数を呼んでる?
    it.Invoke( /*🟠*/ (testCaseDesc, testCaseFunc) =>
    {
        testCases.Add(new(subject, testCaseDesc, testCaseFunc));
    });
});

👇 FUnit.Run 呼び出し側

FUnit.Run(args, /*🟢*/ describe =>
{
    // 🟢 Run から渡された関数を呼んでる(間違いない)
    describe("...", /*🟣*/ it =>  // 🟣 を Run 内部に渡してる?
    {
        // 🟠 Run から受け取ったモノ?
        it("test case", () => Console.WriteLine("Passed!!"));
    });

    // 🟢 Run から渡された関数を呼んでる
    describe("...", ...);
});
  1. 🟢 を Run 内部から渡す
  2. FUnit を使う側から 🟢 を通して 🟣 を Run に渡す
  3. Run 内部で渡された 🟣 を呼んで 🟠 を渡す
  4. FUnit を使う側は 🟠 を呼ぶ
  5. 「???」

多分 Run 内部の変数名が it だけど実際は it の引数として渡す Aciton、みたいな状態が分かり辛くしてる

おわりに

眠い時に作り切ったので覚醒状態だとなんで動いてるか分かりませんが、まあ動いてるんで良いでしょう。

👇 ダウンロードはコチラ

https://github.com/sator-imaging/FUnit

以上です。お疲れ様でした。

Discussion