IL2CPPで非同期メソッドのテストをする

最終更新日


Unityでasync/awaitのテストコードを書くのは手間がかかります。UnityのTestRunnerが非同期のテストに対応していないため、何らかの特殊な方法を取る必要があります。

さらにIL2CPPビルドして実機でテストしようとするとIL2CPPの制限も回避しなければならないために大変なことになります。そういった落とし穴と回避方法を紹介します。

非同期のテストを書く

まずはIL2CPPのことは忘れて非同期メソッドのテストを書きます。

例として以下のAddAsyncメソッドをテストすることにします。50ミリ秒待ってから足し算をするだけのメソッドです。

using System.Linq;
using System.Threading.Tasks;

public static class Calculator
{
    public static async Task<int> SumAsync(params int[] numbers)
    {
        await Task.Delay(50);
        return numbers.Sum();
    }
}

このメソッドをテストする方法を3つ紹介します。

1. Task.Run().Wait()を使う

一番簡単な方法はTask.Run()を使うことです。Task.Run()の中で普通にawaitを使ってテストを書き、Wait()で完了まで待ちます。

using System.Threading.Tasks;
using NUnit.Framework;

public sealed class AsyncMethodTest
{
    [TestCase(new[] { 0, 1, 2, 3 }, 6)]
    [TestCase(new[] { -10, 5, }, -5)]
    public void SumAsync_SimpleValues_Calculated(int[] numbers, int expected)
    {
        Task.Run(async () =>
        {
            int result = await Calculator.SumAsync(numbers);
            Assert.That(result, Is.EqualTo(expected));
        }).Wait();
    }
}

この方法のメリットは単純でわかりやすいことです。

デメリットは、テスト実行中はUnityエディタがフリーズすることです。Wait()でメインスレッドを止めているので当然です。

もしバグで無限ループが発生するといつまでもフリーズしたままなので強制終了するしかありません。テストがたくさんある中でフリーズすると、どのテストで止まっているのかすらわからず自動テストの意味がないです。

もう1つのデメリットとして、Wait()を書き忘れる可能性があることです。忘れるとどうなるかというと、テストコード中の全ての例外が無視されるようになります。つまりAssertに失敗しても検出されません。これまたテストの意味が無いです。表面上はテスト成功になって気付きづらいのでフリーズよりやっかいです。

結論としてこの方法は使ってはいけません。

2. コルーチンとしてテストする

TestRunnerでは非同期のテストが出来ませんがコルーチンのテストならできます。非同期メソッドをコルーチンとして実行することでテストします。

具体的には以下のようなコードになります。

using System;
using System.Collections;
using System.Runtime.ExceptionServices;
using System.Threading.Tasks;
using NUnit.Framework;
using UnityEngine.TestTools;

public sealed class AsyncMethodTest
{
    [UnityTest]
    [TestCase(new[] { 0, 1, 2, 3 }, 6, ExpectedResult = null)]
    [TestCase(new[] { -10, 5, }, -5, ExpectedResult = null)]
    public IEnumerator SumAsync_SimpleValues_Calculated(int[] numbers, int expected)
    {
        Exception exception = null;

        Func<Task> method = async () =>
        {
            try
            {
                // 本当にテストしたい部分はここだけ
                int result = await Calculator.SumAsync(numbers);
                Assert.That(result, Is.EqualTo(expected));
            }
            catch (Exception e)
            {
                // テスト中に例外が発生したら保持しておく
                exception = e;
            }
        };
        Task tank = method.Invoke();

        // Taskが完了するまで待機
        while (!task.IsCompleted)
        {
            yield return null;
        }

        // 例外が発生していたら再スロー
        if (exception != null)
        {
            ExceptionDispatchInfo.Capture(exception).Throw();
        }
    }
}

taskを直接Waitする代わりにtask.IsCompletedを監視して完了するまで待っています。これなら毎フレームyield return null;でUnity側に処理を戻しているのでテスト中もフリーズしません。

このままだと直接テストに関係ない部分が多すぎて読みづらいので、テストを走らせるための部分は別メソッドに独立させます。

using System;
using System.Collections;
using System.Runtime.ExceptionServices;
using System.Threading.Tasks;

public static class AsyncTester
{
    public static IEnumerator Run(Func<Task> code)
    {
        Exception exception = null;

        Func<Task> method = async () =>
        {
            try
            {
                await code.Invoke();
            }
            catch (Exception e)
            {
                exception = e;
            }
        };
        Task task = method.Invoke();

        while (!task.IsCompleted)
        {
            yield return null;
        }

        if (exception != null)
        {
            ExceptionDispatchInfo.Capture(exception).Throw();
        }
    }
}

これを使ってテストコードは以下のように書けます。

using System.Collections;
using NUnit.Framework;

public sealed class AsyncMethodTest
{
    [UnityTest]
    [TestCase(new[] { 0, 1, 2, 3 }, 6, ExpectedResult = null)]
    [TestCase(new[] { -10, 5, }, -5, ExpectedResult = null)]
    public IEnumerator SumAsync_SimpleValues_Calculated(int[] numbers, int expected) => AsyncTester.Run(async () =>
    {
        int result = await Calculator.SumAsync(numbers);
        Assert.That(result, Is.EqualTo(expected));
    });
}

かなりわかりやすくなりました。

デメリットもないので基本的にこの方法で書くのが良いと思います。

3. UniTaskを使う

UniTaskを使っているなら同じように使えるメソッドが用意されているので自分で定義する必要はありません。

次のコードのようにUniTask.ToCoroutineを使います。

using System.Collections;
using Cysharp.Threading.Tasks;
using NUnit.Framework;

public sealed class AsyncMethodTest
{
    [UnityTest]
    [TestCase(new[] { 0, 1, 2, 3 }, 6, ExpectedResult = null)]
    [TestCase(new[] { -10, 5, }, -5, ExpectedResult = null)]
    public IEnumerator SumAsync_SimpleValues_Calculated(int[] numbers, int expected) => UniTask.ToCoroutine(async () =>
    {
        int result = await Calculator.SumAsync(numbers);
        Assert.That(result, Is.EqualTo(expected));
    });
}

UniTaskを使っている場合はこちらを使うのがいいと思います。

IL2CPPでビルドする

ここまでの説明でUnityEditor上でのテストはできるようになりました。ここからはIL2CPPビルドした状態でテストできるようにします。

テストをビルドするには、アセンブリの設定をPlayModeテストにしてTestRunner右上のRun All Testsをクリックすればできます。

しかし上記のAsyncMethodTestをビルドしようとすると以下のようなエラーが発生します。

Exception: IL2CPP error for type ‘System.ValueType’ in assembly ‘C:\UnityTest\Temp\StagingArea\Data\Managed\mscorlib.dll’ System.NotSupportedException: IL2CPP does not support attributes with object arguments that are array types.

IL2CPPでは属性の引数にobject型が使えないと言っています。エラー箇所が出力されず非常にわかりずらいのですが、TestCase属性で配列を渡している部分が引っかかっています。これをなんとかしないといけません。

TestCase属性が使えないならTestCaseSource属性を使ってみます。

using System.Collections;
using Cysharp.Threading.Tasks;
using NUnit.Framework;

public sealed class AsyncMethodTest
{
    public static IEnumerable SumAsync_SimpleValues_Calculated_Cases()
    {
        yield return new object[] { new[] { 0, 1, 2, 3 }, 6 };
        yield return new object[] { new[] { -10, 5, }, -5 };
    }


    [UnityTest]
    [TestCaseSource(nameof(SumAsync_SimpleValues_Calculated_Cases))]
    public IEnumerator SumAsync_SimpleValues_Calculated(int[] numbers, int expected) => UniTask.ToCoroutine(async () =>
    {
        int result = await Calculator.SumAsync(numbers);
        Assert.That(result, Is.EqualTo(expected));
    });
}

こうするとビルドしてテスト実行できるようになります。

しかしテスト結果が失敗になります。これはエディタ上で実行しても同じです。失敗メッセージは「Method has non-void return value, but no result is expected」です。戻り値を指定してあげないといけないようです。

TestCase属性のときは「ExpectedResult = null」で指定してました。TestCaseSource属性ではTestCaseDataを使って指定できます。

using System.Collections;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using NUnit.Framework;
using UnityEngine.TestTools;

public sealed class AsyncMethodTest
{
    private static IEnumerable<TestCaseData> SumAsync_SimpleValues_Calculated_Cases
    {
        get
        {
            yield return new TestCaseData(new[] { 0, 1, 2, 3 }, 6).Returns(null);
            yield return new TestCaseData(new[] { -10, 5, }, -5).Returns(null);
        }
    }



    [UnityTest]
    [TestCaseSource(nameof(SumAsync_SimpleValues_Calculated_Cases))]
    public IEnumerator SumAsync_SimpleValues_Calculated(int[] numbers, int expected) => UniTask.ToCoroutine(async () =>
    {
        int result = await Calculator.SumAsync(numbers);
        Assert.That(result, Is.EqualTo(expected));
    });
}

これでビルドしてテスト実行できるテストコードになりました。


コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

コメントする