読者です 読者をやめる 読者になる 読者になる

#24 「Unityでコルーチンも単体テストしよう」 tech.kayac.com Advent Calendar 2012

unity AdventCalendar2012

みなさんこんばんは。今年の2月に入社してWeb業界というものがよくわからないままiPhoneアプリ開発やらnode.jsでサーバ開発やらPerlでサーバ開発やらC#でUnity開発やらをやっていたら年が暮れかかっていた@acidlemonです。

もともと私はC++が専門で、SIerの業界にいたのでしかたなくJavaもやっていたという感じだったのですが、Web業界に転職してみたらC++でプログラムを書く人がほぼいなかったということで、今年だけで新規に4言語も手をつけていて環境の激変っぷりに自分でも驚いています。

さて、24日間にわたってお送りしてまいりましたtech.kayac.com Advent Calendar 2012も今日が最終日。2日目にしていきなりJavaScriptでおっぱいが動き始めた時はどうなることかと思いました。しかし、振り返ってみるとDDLをGitで管理したり、Procletで開発中のミドルウェアの立ち上げを超簡単にしたりといった開発を効率よく進めるためのイノベーションや、5年前から続いているWebサービスを時間と技術革新の流れに合わせていったり、ユーザの行動ログを可視化してカスタマーサポートを効率化したりといった、運用を効率よく進めるためのイノベーションなのがありましたね! いやーすごい。

昨日のHokkaido.pmのため22〜24日は北海道に行っていたため遅めのアップロードとなりましたが、最終日のアドベントカレンダーいってみましょうー(よくわからないテンションになっています)。

本題

最初にも書きましたが最近はPerlでサーバを書きつつC#でUnityクライアントの通信部分を書くという二足のわらじ(?)のような生活をしております。

で、Perlは単体テストしやすい言語なので大変感動しているのですが、C#/Unityとなるとなかなか苦痛な感じです。私のプロジェクトにはフロント専門のUnityチームがいるので、私は通信部分とモデル部分のみ書いています。つまり表示部分を全く書かずにロジックだけのコードを書いているため、単体テストを書くのはやはり必須。とりあえず世間的にはUnityの単体テストするならSharpUnitだよねみたいなノリになっているようなのですが、通信部分をテストするにはこれもなかなかつらいものがありました。

というのも、SharpUnitは非同期実行(コルーチン実行)をサポートしておらず、同期実行できるメソッドしかテストできないんです。まぁサーバから来たJSONをパースしてクラスオブジェクトを組み立てるところは同期実行なので別によいんですが、通信が絡む部分はUnityEngine.WWWを使う必要があるためどうしてもコルーチンを実行してyield returnでゆっくり待つ必要が出てきます。

「通信部分なんて単体テストの範疇じゃないじゃん、サーバ側のテストでやれよ」というご意見もごもっともなのですが、だからといってサーバに接続出来ないときのエラー等をテストしないわけにも行かないので、そういうところはクライアント側でちゃんとテストする必要があります。それを自動化したかったわけですね!

SharpUnitを改造したよ

ということで、今回はSharpUnitを改造してコルーチンのテストもできるようにしました。

オリジナルのソースがgithubに上がっているようなのでとりあえずforkして改造したものをgithubにあげています

大枠の使い方(Unity上での動かしかた)についてはあんまり変わっておりません。以下の記事が大変参考になります。というか私がまず最初に動かして動作チェックをしたときにこれを参考にさせていただきました。ありがとうございます。

とりあえずUnity側で必要な作業は、テスト時に動かすシーンに対してTestRunnerというGameObjectを追加して、そこにUnity3D_TestRunner.csUnity3D_TestSuite.csをアタッチする感じになります。

2012_24_img.png

さてオリジナルのSharpUnitとの差分ですが、具体的には以下のような点を改造しています。

  • 従来のTestCaseの使用感は変更せず、それとは別にIEnumeratorを戻り値とする関数をテストできるUnityTestCaseクラスを追加
  • TestCaseクラスからITestCaseインタフェースを分離
  • TestSuiteクラスを使うのはやめて、Unity3D_TestSuiteクラスを追加。ITestCaseを実装しているTestCaseクラスとUnityTestCaseクラスの両方に対応(ファイルはそのまま残しています)

改造理由についてちょっと触れておくと、TestCaseクラスはコルーチン実行できないテストフレームワークなので、これを元にコルーチンを実行できるUnityTestCaseクラスを作りました。Suite側でTestCaseとUnityTestCaseの両方を同じように扱えるようにするため、UnityTestCase側のインタフェースをITestCaseに分離し、TestCaseをそれに適応するようにTestResultを受け取る部分のインタフェースを変更しています。

これでTestSuiteでTestCaseとUnityTestCaseを同じように扱えるようになったのですが、そもそもTestSuiteからテストケースのコルーチンを実行するにはTestSuite自体がMonoBehaviorを継承してGameObjectとしてシーンにアタッチできるようになっている必要があるため、TestSuiteをベースにUnity3D_TestSuiteクラスを作ってGameObjectとして利用できるようにしています。

C++脳の私はこの辺の再実装は多重継承使ってやればいいじゃーんと思ってたんですが、C#には多重継承の仕組みがなかったのでてっとり速くコピペですませてしまっています。コレ書いててmix-in的なことをやればよかったんじゃねって今思いました。

どんな感じでつかうの?

そもそもSharpUnitを使ったことがある人はこの記事読んでも「へー」くらいで終わるかと思うんですが、Unityで単体テストとかやんねーだろ的な人もいるかと思いますので、具体的な例をこちらに書きます。

たとえば、こんなクラスがあったとしましょう。

class Plan {
    private string _Title;
    private string _Text;
    public string Title { get { return _Title; } }
    public string Text { get { return _Text; } }

    // コンストラクタ
    public Plan(string title, string text) {
        _Title = title;
        _Text = text;
    }
}

このオブジェクトをテストするとなるとこんなコードを書きます。

using SharpUnit;

public class PlanTestCase : TestCase {
    public override void SetUp() {
    }

    public override void TearDown() {
    }

    [UnitTest]
    public void TestConstructor() {
        // ふつうに作る
        Plan val1 = new Plan("Christmas", "Let's play tennis!");

        Assert.Equal(val1.Title, "Christmas", "title ok");
        Assert.Equal(val1.Text, "Let's play tennis!", "text ok");

        // 本文がぬるぽ
        Plan val2 = new Plan("Christmas", null);
        Assert.Equal(val2.Title, "Christmas", "title ok");
        Assert.Null(val2.Text, "text is null ok");
    }
}

ここまでは既存のSharpUnitで可能でした。ここからが問題で、かなり誰得感はありますがPlanをインターネットから取ってきて作るファクトリクラスがあったとしましょう。まーUnityでやる必要性はないですがサンプルなのでそんなもんです。

using UnityEngine;

class InternetPlanFactory {
    private Plan _Plan;
    public Plan Plan { get { return _Plan; } }

    // 特定のURLにアクセスし、1行目をタイトル、2行目をテキストとしてオブジェクトを生成
    public IEnumerator PlanFromUrl(string url) {
        WWW www = new WWW(url);
        yield return www;

        if (www.error) {
            // ノープランだ
            _Plan = null;
        } else {
            // 適当にPlanつくる
            string[] lines = www.text.Split('\n');
            string title = lines[0];
            string text = lines.Length > 1 ? lines[1] : "";

            _Plan = new Plan(title, text);
        }
    }
}

このファクトリクラスをテストするならまぁ大体この2ケースでしょうか(ホントは1行しかなかったときのテストもしたいですね)。

  • ふつうにうまくいった
  • 接続出来なかったり404とかのエラーが返ってきたりした

が、このファクトリクラスのメソッドはコルーチン実行して結果が帰ってくるのを待たないとちゃんとテストできません。そこで、今回使ったUnityTestCaseを使います。

using SharpUnit;

public class InternetPlanFactoryTestCase : UnityTestCase {
    private InternetPlanFactory factory;
    public override void SetUp() {
        factory = new InternetPlanFactory();
    }

    public override void TearDown() {
        factory = null;
    }

    [UnitTest]
    public IEnumerator TestPlanFromUrlOk() {
        // 普通にうまくいきそうなパターン
        Unity3D_TestSuite runner = GameObject.Find("TestRunner").GetComponent<Unity3D_TestSuite>();
        yield return runner.StartCoroutine(factory.PlanFromUrl("http://www.example.org/example.txt"));

        try {
            Assert.NotNull(factory.Plan, "plan is not null ok");
            Assert.NotNull(factory.Plan.Title, "plan has title ok");
            Assert.NotNull(factory.Plan.Text, "plan has text ok");
        } catch (TestException e) {
            MarkAsFailure(e);
            yield break;
        }

        DoneTesting();
    }

    [UnitTest]
    public IEnumerator TestPlanFromUrlError() {
        // ダメっぽそうなパターン
        Unity3D_TestSuite runner = GameObject.Find("TestRunner").GetComponent<Unity3D_TestSuite>();
        yield return runner.StartCoroutine(factory.PlanFromUrl("http://www.example.org/error.txt"));

        try {
            Assert.Null(factory.Plan, "plan is null ok");
        } catch (TestException e) {
            MarkAsFailure(e);
            yield break;
        }

        DoneTesting();
    }

}

さていくつか普通のTestCaseと違う点が出てきました。1つめはテスト関数の戻り値がIEnumeratorになってることですね。テストを走らせる呼び出し元が yield return StartCoroutine(testMethod); というような感じで呼び出しますのでこれに対応できるようにIEnumeratorにします。これによりテスト関数の中でyield returnを使えるようになるというわけですね。

2つめはAssertをtryで囲んでいることです。普通のTestCaseであればAssertでTestExceptionが飛んだら呼び出し元がcatchしてよしなにエラーレポートしてくれるところなんですが、C#にはtry-catchブロックの中にyield returnを書けないという制限があります。まぁ複雑になるしそれで正解だと思います。が、それによりテストを走らせる呼び出し元でcatchする作戦が使えませんので、テスト関数の中でちゃんとtry-catchして、catchしたらUnityTestCaseが持ってるMarkAsFailure関数でTestExceptionが発生したことをマークしておきます。呼び出し元はyield returnからコルーチンが帰ってきたらTestExceptionがセットされているかをチェックしています。

3つめはStartCoroutineの呼び出し方です。テストはコルーチンとして実行する必要があるのですが、コルーチンとして実行するにもロジックしかないメソッドのためGameObjectがありません。そこで、テストを走らせるためにシーンに追加したGameObjectからUnity3D_TestSuiteクラスのコンポーネントを取得して、こいつをコルーチンランナーとして活用しています。

あとDoneTesting()っていうのを呼んでいるのはPerlでテストを書いている人にはおなじみのdone_testingですね。IEnumeratorな関数である以上適当に途中にyield breakを書いてしまうとそこでテストを中断出来てしまい、テストが全部走らないままパスしたように見えてしまうことがありますので、それの対策にDoneTesting()を呼ばないとFailする機構をいれておいたというところです(まぁあってもなくてもいいとは思いますが、趣味で入れてしまったものです)。

まとめ

かけ足で紹介してきましたUnityでも使えるテストフレームワークSharpUnitの改造版、私はC#歴が浅いため結構これで満足しちゃっているのですが、もし「こうしたらもっと使いやすくなると思う!」などありましたらコメントいただければとおもいます。

それでは皆さん、よいクリスマス、そしてよい新年をお迎えください。カヤック技術部のアドベントカレンダーを購読いただきありがとうございました!