LoginSignup
36
31

More than 3 years have passed since last update.

Unityでテストコードを書いて会得した最強のTips(苦肉の策ともいう)

Last updated at Posted at 2019-08-20

最強のTips(苦肉の策)が生まれるきっかけ

自分が現在開発に携わっているゲームでは、作中に多くのシナリオが含まれています。
その数はデータにして200を超えていますが、このデータが増えるにつれて、ある問題が発生しました。

それは、シナリオデータの数が多すぎて、不具合のあるデータがあることに気付けないということです。
(特定の選択肢を選ぶと、進行不能になるデータがいくつか見られました)

これに対処するため、「全シナリオの動作チェックを行うUIのテストコードを書こう!」と思ったのが、事の始まりです。

まさか、あんな苦肉の策を取ることになるとは、この時は考えてもいませんでした…

目次(Tips一覧)

自分がテストコードを書いて会得したTipsを紹介します。

ちなみに、最強のTips(苦肉の策)は「6. テストの失敗が後続のテストにまで影響を与えないようにする」です。
それ以外は、まとも(のはず…)なTipsになります。

1. Play Modeテストで使うAttributeとその注意点
2. テスト時間を短縮する
3. 手軽にUIテストをできるようにする
4. テストの失敗をハンドリングする
5. コード側からTest Runnerを制御する
6. テストの失敗が後続のテストにまで影響を与えないようにする
7. CommandLineからTest Runnerを実行する

Tipsを紹介する前に

テストコードを書くにあたって、Unity上でテストできるというメリットから、Unityに標準で付属しているTest RunnerのPlay Modeテストを使用しています。

Play Modeテストはコルーチンを使って記述され、このコルーチンが最後までエラーを吐かずに終了すればテストが成功扱いになるという、とてもシンプルなものです。

public class ScenarioTest
{
    [UnityTest]
    public IEnumerator Run()
    {
        Debug.Log("テスト開始");

        // シナリオの再生処理
        yield return RunScenario();

        Debug.Log("最後までいったのでテスト成功!!");
    }
}

これはUnityエディタから実行できるようになっており、実行すると自動的にUnityがPlay状態になり、直後にコルーチンを実行する仕組みとなっています。

test.gif

そしてもちろん、CommandLineからの実行にも対応しています。完璧ですね。

Unity.exe -projectPath <projectPath> -batchmode -runTests -testPlatform playmode -testResults <resultPath>

 
これから紹介するTipsは、このTest RunnerのPlay Modeテストを行う際のTipsとなります。

Tips

1. Play Modeテストで使うAttributeとその注意点

Test Runnerを使う際に覚えておきたいAttributeがあります。
また、Play Modeテストにおいて注意すべき点もあるので、それも交えて紹介します。

テストメソッド用

  • [UnityTest]
    • テストしたいコルーチンに必ず付ける必要がある
  • [TestCase]
    • テストケースを定義する
  • [TestCaseSource]
    • テストケースをリストで定義する(動的に定義することができる)
  • [Timeout]
    • テストのタイムアウト時間を指定する
public class ScenarioTest
{
    // テストしたいコルーチンに必ず付ける必要がある
    [UnityTest]
    public IEnumerator Run()
    {
        yield return RunScenario();
    }


    // コルーチンに引数として値を渡すことができる(複数指定可)
    // この例だと、RunWithTestCase("file_0001")、RunWithTestCase("file_0002")として扱われる
    // 【注意】 コルーチンに[TestCase]を使う場合、ExpectedResult = null を記述しないとエラーになる
    [TestCase("file_0001", ExpectedResult = null)]
    [TestCase("file_0002", ExpectedResult = null)]
    [UnityTest]
    public IEnumerator RunWithTestCase(string file)
    {
        yield return RunScenarioWithFile(file);
    }



    // [TestCaseSource]に渡すリスト
    public static IEnumerable FILES
    {
        get
        {
            foreach (var path in Directory.GetFiles("<path>"))
            {
                //  【注意】 [TestCase]同様、Returns(null) を記述しないとエラーになる
                yield return new TestCaseData(path).Returns(null);
            }
        }
    }
    // [TestCase]をリストで管理できるようにしたもの(動的に定義したい場合などで有用)
    // 各要素ごとを渡したテストとして扱われる
    // Directory.GetFilesから取得できる文字列がfile_0001、file_0002とすると、
    // RunWithTestCaseSource("file_0001")、RunWithTestCaseSource("file_0002")となる
    [TestCaseSource("FILES")]
    [UnityTest]
    public IEnumerator RunWithTestCaseSource(string file)
    {
        yield return RunScenarioWithFile(file);
    }



    // テストのタイムアウト時間を指定する
    // デフォルトだと30秒
    // ms単位で指定(この例だと5分になる)
    [Timeout(300000)]
    [UnityTest]
    public IEnumerator RunWithTimeout()
    {
        yield return RunScenario();
    }
}

ちなみにUnityエディタ上ではこう見えています。

gui.png

コールバック

  • [OneTimeSetUp]
    • クラス内で最初のテストが実行される前に一度だけ呼ばれる
  • [SetUp]
    • 各テストの最初に呼ばれる
  • [TearDown]
    • 各テストの最後に呼ばれる(テストが失敗しても呼ばれる)
  • [OneTimeTearDown]
    • クラス内で最後のテストが実行された後に一度だけ呼ばれる
public class ScenarioTest
{
    // クラス内で最初のテストが実行される前に一度だけ呼ばれる
    [OneTimeSetUp]
    public void OneTimeSetUp()
    {
        Debug.Log("テスト開始");
    }


    // 各テストの最初に呼ばれる
    [SetUp]
    public void SetUp()
    {
        Debug.Log("file_0001 のテスト開始");
    }


    // 各テストの最後に呼ばれる(テストが失敗しても呼ばれる)
    [TearDown]
    public void TearDown()
    {
        Debug.Log("file_0001 のテスト終了");
    }


    // クラス内で最後のテストが実行された後に一度だけ呼ばれる
    [OneTimeTearDown]
    public void OneTimeTearDown()
    {
        Debug.Log("テスト終了");
    }
}

なお、Test RunnerにはNUnitと呼ばれるテストフレームワークが使われているため、もっと知りたい方はNUnitのドキュメントを参照ください。

2. テスト時間を短縮する

Play Modeテストでは、UnityをPlay状態にしてテストを実行するため、最速でも実プレイと同じ速度しか出ません。

そこで、ゲーム内速度やFPSを上げることでスピードUPを図ります。

public class ScenarioTest
{
    // クラス内で最初のテストが実行される前に一度だけ呼ばれる
    [OneTimeSetUp]
    public void OneTimeSetUp()
    {
        // テスト時間短縮のために高速化する
        Application.targetFrameRate = 60;
        Time.timeScale = 2.0f;
    }
}

これだけで、テスト時間を大幅に削減することができます。
Play Modeテストならではの手法ですね。

3. 手軽にUIテストをできるようにする

シナリオの動作チェックのようなUIテストを行う場合、画面タップなどのUIの操作が必要となります。

ただ、これを自前で実装するのは手間になるため、すでにあるUnity UI Test Automation Frameworkというフレームワークを使用することをオススメします。

これを使えば、

public class ScenarioTest : UITest
{
    [UnityTest]
    public IEnumerator Run()
    {
        // targetという名前のボタンを押す
        yield return Press("target");

        // Scenarioシーンをロードする
        yield return LoadScene("Scenario");

        // targetというオブジェクトが登場するまで待機する
        yield return WaitFor(new ObjectAppeared("target"));
    }
}

といったことが簡単にできるようになります。

4. テストの失敗をハンドリングする

テストコードを書いていて、テストが失敗した場合のみ特定の処理をしたい、ということがあると思います。

もし、[TearDown]で指定したメソッドに引数として情報が渡ってきたり、テスト失敗時に呼ばれるコールバックなどがあればよかったのですが、NUnitのドキュメントを見た限りなさそうでした。

ではどうするか。
Play Modeテストはエラーログが出れば失敗となるので、Unityのログメッセージを取得することでハンドリングできるようになります。

public class ScenarioTest
{
    private List<string> _errorLogs = new List<string>();

    // クラス内で最初のテストが実行される前に一度だけ呼ばれる
    [OneTimeSetUp]
    public void OneTimeSetUp()
    {
        // ログを残す
        Application.logMessageReceived += Log;
    }

    public void Log(string logString, string stackTrace, LogType type)
    {
        // エラーログを保持しておく
        if (type == LogType.Error || type == LogType.Exception)
        {
            _errorLogs.Add($"{logString}\n{stackTrace}\n");
        }
    }

    [UnityTest]
    public IEnumerator Run()
    {
        // 各テストの頭でクリアする
        _errorLogs.Clear();

        yield return RunScenario();
    }

    // 各テストの最後に呼ばれる(テストが失敗しても呼ばれる)
    [TearDown]
    public void TearDown()
    {
        // エラーログがあるならテスト失敗
        if (_errorLogs.Count > 0)
        {
            Debug.Log("テスト失敗");
            Debug.Log(string.Join("\n", _errorLogs.ToArray()));
        }
    }
}

こうすれば、失敗時のみ処理をすることができます。

5. コード側からTest Runnerを制御する

Test RunnerがUnityエディタやCommandLineから実行できる術を用意しているとしても、やはり自前で拡張したい時はあります。

そういった場合、コード側からTest Runnerを制御することになると思いますが、実はこれがかなり面倒です。
理由は単純。Test RunnerのAPIが公開されていないからです。

そのため、リフレクションを使って無理やり実行することになります。

public class ScenarioTestCommand
{
    public static void Execute()
    {
        // テスト情報を追加する
        var engineAssembly = Assembly.Load("UnityEngine.TestRunner");
        var testFilterType = engineAssembly.GetType("UnityEngine.TestTools.TestRunner.GUI.TestRunnerFilter");
        var testFilter = Activator.CreateInstance(testFilterType);
        // テストの名前を使って実行する
        // 実行するテスト名(メソッド名)は名前空間とクラス名も含めること
        var testNamesField = testFilterType.GetField("testNames");
        testNamesField.SetValue(testFilter, new string[] { "ScenarioTest.Run(\"file_0001\")", "ScenarioTest.Run(\"file_0002\")" });

        // Test Runnerを実行できるクラスを参照する
        var editorAssembly = Assembly.Load("UnityEditor.TestRunner");
        var runnerWindowType = editorAssembly.GetType("UnityEditor.TestTools.TestRunner.TestRunnerWindow");
        var runnerWindow = runnerWindowType.GetField("s_Instance", BindingFlags.NonPublic | BindingFlags.Static).GetValue(null);
        var listGUIField = runnerWindowType.GetField("m_PlayModeTestListGUI", BindingFlags.Instance | BindingFlags.NonPublic);
        var listGUI = runnerWindow != null ? listGUIField.GetValue(runnerWindow) : Activator.CreateInstance(listGUIField.FieldType);

        // Test Runnerを実行する
        var runMethod = listGUIField.FieldType.GetMethod("RunTests", BindingFlags.Instance | BindingFlags.NonPublic);
        runMethod.Invoke(listGUI, new object[] { testFilter });
    }
}

正直「何やっているんだ、このコードは」状態になると思うので、dnSpyなどのデコンパイラを使ってTestRunner.dllの中身を見ることをオススメします。

6. テストの失敗が後続のテストにまで影響を与えないようにする

Play Modeテストでは、複数のテストを実行した場合も、同じPlay状態でテストを実行します。
そのため、変数などで状態を持っていれば、それは後続のテストにも引き継がれることになります。

この特性は、途中のテストが失敗した時に厄介で、

  1. テストAが失敗する
  2. テストAが途中で失敗したので、途中の状態が保持されたままになる
  3. 次のテストBに移る
  4. 想定しない状態が保持されていることで、問題ないはずのテストBが落ちてしまう

ということが起きてしまいます。

fail.png

これに対処するには、各テストが正しく初期化された状態で実行される必要があります。
ただ、もしstaticやDontDestroyOnLoadをよく使っているプロジェクトであれば、これは困難を極めます。

シーンの再ロードなどでは初期化できず、しらみつぶしに初期化されていないところを探していくしかないからです。
自分が現在開発に携わっているゲームも同じ状況で、初めはしらみつぶしに探していましたが、途方もなく途中で断念しました。

打開策はないか…そう思案して、考えついた最強のTips(苦肉の策)、それが

一旦UnityのPlay状態を止めて再度Play状態にすれば、事実上初期化されたことと同義になる!!(スマホのタスクキルと同じ)

というものでした。

実際のコードを見てみましょう。

public class ScenarioTest
{
    private static object _testFilter = null;

    private List<string> _errorLogs = new List<string>();
    private List<string> _runTestNames = new List<string>();

    // クラス内で最初のテストが実行される前に一度だけ呼ばれる
    [OneTimeSetUp]
    public void OneTimeSetUp()
    {
        // 【注意】 この例では、Logメソッドを省略
        Application.logMessageReceived += Log;

        // Restartするため、テスト情報を保持しておく
        var assembly = Assembly.Load("UnityEngine.TestRunner");
        var controllerType = assembly.GetType("UnityEngine.TestTools.TestRunner.PlaymodeTestsController");
        var controllerObject = GameObject.Find("Code-based tests runner");
        var controller = controllerObject.GetComponent(controllerType);
        var settingsField = controllerType.GetField("settings");
        var settings = settingsField.GetValue(controller);
        var settingsType = assembly.GetType("UnityEngine.TestTools.TestRunner.PlaymodeTestsControllerSettings");
        var filterField = settingsType.GetField("filter");
        _testFilter = filterField.GetValue(settings);
    }

    // 【注意】 この例では、FILES変数の宣言は省略
    [TestCaseSource("FILES")]
    [UnityTest]
    public IEnumerator Run(string file)
    {
        // 前回のテストが失敗していたら、UnityのPlay状態を止める
        if (_errorLogs.Count > 0 && _testFilter != null)
        {
            // 【注意】 テスト情報の更新処理(今回は名前で更新しているが、カテゴリーなら別の処理が必要)
            var assembly = Assembly.Load("UnityEngine.TestRunner");
            var testFilterType = assembly.GetType("UnityEngine.TestTools.TestRunner.GUI.TestRunnerFilter");
            var testNamesField = testFilterType.GetField("testNames");
            var testNames = (string[])testNamesField.GetValue(_testFilter);
            // すでに実行しているテストは削除する
            testNames = testNames.Where(testName => !_runTestNames.Any(runTestName => testName.Contains(runTestName))).ToArray();
            testNamesField.SetValue(_testFilter, testNames);

            // Unityを止めて、Test Runnerを再び実行する
            EditorApplication.isPlaying = false;
            EditorApplication.update += OnRestart;
        }

        _errorLogs.Clear();
        _runTestNames.Add(file);

        yield return RunScenarioWithFile(file);
    }

    private static void OnRestart()
    {
        Restart(_testFilter);
    }

    // コード側からTest Runnerを実行する
    private static void Restart(object testFilter)
    {
        var assembly = Assembly.Load("UnityEditor.TestRunner");
        var runnerWindowType = assembly.GetType("UnityEditor.TestTools.TestRunner.TestRunnerWindow");
        var runnerWindow = runnerWindowType.GetField("s_Instance", BindingFlags.NonPublic | BindingFlags.Static).GetValue(null);
        var listGUIField = runnerWindowType.GetField("m_PlayModeTestListGUI", BindingFlags.Instance | BindingFlags.NonPublic);
        var listGUI = runnerWindow != null ? listGUIField.GetValue(runnerWindow) : Activator.CreateInstance(listGUIField.FieldType);
        var runMethod = listGUIField.FieldType.GetMethod("RunTests", BindingFlags.Instance | BindingFlags.NonPublic);
        runMethod.Invoke(listGUI, new object[] { testFilter });
        EditorApplication.update -= OnRestart;
    }
}

やっていることは、以下の通りです。

  1. テスト開始時にTest Runnerからテスト情報を抜き出す
  2. 各テスト開始時、前回のテストが失敗したか確認する
  3. テストに失敗していれば、すでにテスト済みのものを、開始時の保持しておいたテスト情報から削除する
  4. UnityのPlay状態を止め、更新したテスト情報をもとにTest Runnerを再実行する

Test Runnerを実行すれば、UnityはPlay状態になるため、事実上の初期化が完成です。

注意として、テスト情報が名前ではなくカテゴリーで管理されていることもあるため、その際は別の更新処理が必要です。
 
 
苦肉の策ではありますが、これで全シナリオのテストが可能となりました。

ただ一点、この方法で問題になることがありました。
次のTipsに移ります。

7. CommandLineからTest Runnerを実行する

通常のケース

冒頭でも記述した、Test Runnerで用意されているコマンドで実行可能です。

Unity.exe -projectPath <projectPath> -batchmode -runTests -testPlatform playmode -testResults <resultPath>

これに-quitが指定されていないことにお気づきでしょうか?

実は、このコマンドを実行すると、テスト終了時にUnityが自動的に終了するようになっています。
逆に、-quitを付けると失敗してしまうためご注意ください。

特殊なケース

6. テストの失敗が後続のテストにまで影響を与えないようにする」で紹介した方法だと、通常のケースと同じコマンドを実行しても上手くいきません。

問題は2つあります。
解決策とともに、一つずつ紹介していきます。

Unityが終了しない問題

通常のケースにて紹介した、-quitを指定せずとも、テスト終了時にUnityが自動的に終了する機能。
本来であれば問題がないこの挙動ですが、先ほど紹介した一旦Play状態を止める方法だと厄介です。

というのも、一旦Play状態を止めるという動作は、Test Runnerが不正終了したとみなされ、その機能が働かなくなってしまうからです。

このままでは、CommandLineからの実行時、途中でテストに失敗すると、Unityが終了されません。

そこで、batchmodeでの実行であれば、全テスト終了時にUnityを終了するようにしました。

public class ScenarioTest
{
    // クラス内で最後のテストが実行された後に一度だけ呼ばれる
    [OneTimeTearDown]
    public void OneTimeTearDown()
    {
        // batchmodeなら終了させる
        if (Environment.CommandLine.Contains("-batchmode"))
        {
            // エラー扱いにするため、1以上を返しても良い
            EditorApplication.Exit(0);
        }
    }
}

途中でテストが失敗しPlay状態が止められても、[OneTimeTearDown]は呼ばれないことが肝ですね。
(複数のクラスが存在する場合は、他のクラスのテストが終了しているかのチェックが必要になります)

無限に同じテストを実行され続ける問題

もしこの記事のコードをそのまま使っている場合、無限に同じテストが実行され続けてしまいます。

これは、

Unity.exe -projectPath <projectPath> -batchmode -runTests -testPlatform playmode -testResults <resultPath>

このコマンドで作成されるテスト情報は名前で管理されていないため、記事のコードのように、すでに実行したテストの名前を削除しても意味がないためです。

もし、この記事のコードを使い回したい場合は、名前で管理されているテスト情報を作成しTest Runnerで実行するメソッドを、CommandLineから呼ぶことをオススメします。

Unity.exe -projectPath <projectPath> -batchmode -executeMethod ScenarioTestCommand.Execute

実行しているScenarioTestCommand.Executeメソッドは、「5. コード側からTest Runnerを制御する」で紹介しているコードを参照してください。
こちらのサンプルコードは、名前で管理されているテスト情報を作成し、実行しています。

まとめ

自分の開発しているゲームでは、この全シナリオ動作テストを毎朝走らせ、失敗すればslackに通知するようにしています。

今回テストコードを入れたことで、シナリオデータの不具合が消えただけでなく、シナリオ再生用のコードを変更するハードルが下がったことも嬉しいポイントです。

ゲーム開発だとテストコードを書く機会も少なかったですが、この機会に今後も積極的に取り組んでいきたいと思います。

Twitter: @yukiarrr

その他参考文献

36
31
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
36
31