UniRxを支えるユニットテスト - RuntimeUnitTestToolkit for Unity

オープンなようなクローズドなような、ラウンドテーブルディスカッションのような、少人数のところでUnityのユニットテストについて話してきました。というか、UniRxのために作って、以降、私の作るUnity用の色々なので使いまわしてる自作のユニットテストフレームワークについて、ですね。

RuntimeUnitTestToolkit for Unity from Yoshifumi Kawai

このフレームワークはずっとUniRxの中に埋まったまんまだったんですが、使える形でパッケージしたのを、今日GitHubに公開しました。unitypackageとしても置いてあるので、一応インポートはしやすいはずです。

とりあえず必要な機能しか入れてないんで、汎用テストフレームワークとしては足りない機能が普通に多いので、その辺も作ってからアセットストアに公開したいなぁ、と思ってはいたんですが、まぁそうなるといつまで経っても公開できなさそうなので、とりあえず現段階のもので公開、です。

.NETのテスト事情、或いはUnityでテストを書かないことについて

私はライブラリとしてはふつーの.NETと共通で動くものを作ることが多いんで、まぁそういう場合は大部分はふつーの.NETのユニットテストを書いたほうが遥かに書きやすいでしょう!つまりUnityでテストを書くコツはUnityで書かないということです!!!みもふたもない。

image

テストのメソッドを右クリックしてデバッグ実行で直接Visual Studioのデバッガでダイレクトにアタッチできたりとか、基本的に最高ですね。

さて、スライドにも書いたのですが、最近はxUnit.netを好んで使っています。MSTestはいい加減投げ捨てていいでしょう、というか投げ捨てるべきでしょう。NUnitは知らん。いらん。補助としてChainingAssertionは変わらず使ってるんですが、.NETCore対応を内部では作って使ってるんですが公開には至ってない……。

また、モックライブラリとしてはMicrosoft Fakes Frameworkのような大仰なものは「絶対に」使うべきではない、という思いが強くなってます。テストはただでさえ負債になりやすいのに(盲目的にテストは書くべき信仰してる人は、テストの負債化に関して全く言及しないのがポジショントークなのか脳みそお花畑なのか、頭悪そうですね)、大きな自動生成を伴うものは負債の連鎖を作りやすいなー、と。シンプルに作らないと、シンプルに投げ捨てることができない、というね。そして、投げ捨てるのは簡単ではなく、投げ捨てるのもまた技術なわけです。

RuntimeUnitTestToolkit

.NETでテスト書くからそれでOK、というわけは当然なくて、Unityだけでしか動かない部分もあるし、そもそもUnityでちゃんと動くかどうかの保証はない。更にはIL2CPPに通した場合はやっぱり別物の挙動というか動かなくなるケースは「非常に多い」ので、ちゃんとIL2CPPで動くことを保証しなければならない。そこで作ったのがRuntimeUnitTestToolkitです。Unityには標準でテストツールあるじゃん、って話ですが、あれは実機動作させられないので論外です。それで用が満たせりゃあ標準の使うわ。

image

テストが並べられて、ボタン押したら実行、ボタンが緑になったら成功、赤になったら失敗というシンプルなふいんきのものです。一個のシーンになってるので、ビルドして実機転送すればそのまま実機で動きます。

実際に自分で使うには、Releaseページからunitypackageを落としてきてインポート。で、UnitTest.sceneを開いて再生すればOK。簡単簡単。

テストの書き方ですが、基本的にはMonoBehaviourを継承したりもしないシンプルなクラスを用意します。

// make unit test on plain C# class
public class SampleGroup
{
    // all public methods are automatically registered in test group
    public void SumTest()
    {
        var x = int.Parse("100");
        var y = int.Parse("200");

        // using RuntimeUnitTestToolkit;
        // 'Is' is Assertion method, same as Assert(actual, expected)
        (x + y).Is(300);
    }

    // return type 'IEnumerator' is marked as async test method
    public IEnumerator AsyncTest()
    {
        var testObject = new GameObject("Test");

        // wait asynchronous coroutine(UniRx coroutine runnner)
        yield return MainThreadDispatcher.StartCoroutine(MoveToRight(testObject));

        // assrtion
        testObject.transform.position.x.Is(60);

        GameObject.Destroy(testObject);
    }

    IEnumerator MoveToRight(GameObject o)
    {
        for (int i = 0; i < 60; i++)
        {
            var p = o.transform.position;
            p.x += 1;
            o.transform.position =  p;
            yield return null;
        }
    }
}

属性とかは特に必要なく、戻り値voidのパブリックメソッドは強制的にテストメソッドとして認識します。また、戻り値IEnumertorのクラスは非同期テストメソッドとして認識してコルーチンとして動かすので、中でyieldとか他のコルーチンを動かしての待機とかも自由にできます。

さすがに定義だけでテストクラスを認識できないので、それとは別にテストローダーを書いてあげます。

public static class UnitTestLoader
{
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    public static void Register()
    {
        // setup created test class to RegisterAllMethods<T>
        UnitTest.RegisterAllMethods<SampleGroup>();

        // and add other classes
    }
}

これで実行してやれば、書いたクラスが実行時にボタンとしてシーンに追加されます。

ある程度リフレクションでメソッドとかの認識をしているんですが、ちゃんとIL2CPPで動作するギリギリのリフレクション加減で仕上げつつ、書きやすい直感的にAPIに仕立てたというのが工夫ポイントですね!

with UniRx

UniRxは結構ユニットテスト向けだったりします。例えば何かアクションを加えてイベントが発行されることを確認したい、という場合に、IObservableとして公開されているならば

public IEnumerator WithUniRxTestA()
{
    // subscribe event callback
    var subscription = obj.SomeEventAsObservable().First().ToYieldInstruction();

    // raise event 
    obj.RaiseEventSomething();

    // check event raise complete
    yield return subscription;

    subscription.Result.Is();
}

と、サクッと書けたりします。あるいは、何か色々によって色々値が変わるということは

public IEnumerator UniRxTestB()
{
    // monitor value changed
    var subscription = obj.ObserveEveryValueChanged(x => x.someValue).Skip(1).First().ToYieldInstruction();

    // do something
    obj.DoSomething();

    // wait complete
    yield return subscription;

    subscription.Result.Is();
}

と、ObserveEveryValueChangedで外側からサクッと値の監視が可能です。また、各種のObservableTriggerを突っ込むことによって、外側から内部の状態をサクッとモニタできます。あまり実際のプログラムでは使うことはないようなことも、ユニットテストなら派手に使っても構わないし、そういう時に楽ができるツールがUniRxには揃っています。外側からサクッとどうこうする手段がないと、インスペクタにユニットテスト用の特別な何かを仕込んでアサートとかいう、しょぼいテストフレームワーク(UnityのIntegration Test Frameworkのことですよ!)になってしまいがちですので。

まとめ

現状のUnityの単体テストツールは、必要な要件を全く満たしてなくて使えなさすぎですぅ。テストツールは結構大事で、とりあえずテスト大事、とりあえずテスト書くんだ、とかいってしょうもないツールを土台にやってるとボロボロに負債になるんで、ちゃんと自分の要件を意識して選択しないとダメですね。そこも把握できてなかったり、あとシンタックスも非常に大事で、Spec系がぶっちゃけ書き方違うだけで本質的に変わらないのに非常に感触が変わるのと同じで、そういうの大事にできない人はプログラミングの感性足りてないんで、小手先のテスト信仰とかしてないで、それ以前にまともな感性磨いたほうが良さそうですね。

とはいえ、Unity 5.6から良くなる気配を見せていて、少なくともその延長線上にはちゃんとした未来がありそうなだけの土台は作れてそうなので良かった。それ以前の(現在の)は本当にセンスなさすぎて、こいつらの感性の先に未来はなさそうだなー、と思ってたんで。

RuntimeUnitTestToolkitをオススメするかっていうと、実機で動かすのに困ってればいいんじゃないでしょうか!とはいえ、素朴すぎるってところはあるんで、もう少し作り込まないと使えないというケースは多そうってところです。私も、自分の作る程度の規模では困ってないんですが、会社のプロジェクトに入れると困るところは多く出てきそうだなー、という感じですね。足らないところを自分で補っていけるならというところです。

近況

ところでなんと今年に入ってブログ書いてなかった!はうう!というのは、書きかけのプロジェクトが多くてそれにあくせくあくせくだからんですねえ。公開まであともう一歩、というところまでに持ってけているのは MessagePack for C#(.NET, .NET Core, Unity, Xamarin) です。

ZeroFormatterあるじゃん、なのに何故、って話ですが、まぁそれは公開時にでも。とりあえず、エクストリーム速いです。それと、拡張性も重視して組んでいて、Unity用の特殊な拡張をアドオンとして有効化すると、例えばVector3[]のシリアライズ/デシリアライズがJsonUtilityの50倍高速化(50倍!)とか、色々強力で強烈になってます。乞うご期待。

それと会社ブログ - Grani Engineering Blog始めましたということで、そっちに幾つか記事書いてますね。C#のswitch文のコンパイラ最適化についてとか。あとgRPC化とか。

NextGen Server/Client Architecture - gRPC + Unity + C# from Yoshifumi Kawai

こちらも、シリアライザのMessagePack for C#化とか大工事を何度かしつつも、もうすぐとりあえずStableといえるとこまで持ってけそうです。

また、Unity用のインメモリ内蔵データベースとしてMasterMemoryというのも作っていて

*neuecc/MasterMemory

これももうすぐ公開できそうかもかもといったところで、とりあえず色々あって大変大変。どれもUnityでのユニットテストには RuntimeUnitTestToolkit で動かしてるんで、私自身は超ヘビーに使いまくってますよ、です。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive