Support for async tests via an attribute

A little trick on how to convert asynchronous tests into Unity IEnumerator tests using an attribute.

Attribute

[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)]
public class TestAsyncAttribute : NUnitAttribute, ISimpleTestBuilder, IImplyFixture
{
    private readonly NUnitTestCaseBuilder _builder = new NUnitTestCaseBuilder();

    public TestMethod BuildFrom(IMethodInfo method, Test suite)
    {
        var parms = new TestCaseParameters(new object[] {method, suite})
        {
            ExpectedResult = new object(), 
            HasExpectedResult = true
        };

        Type type = GetType();
        MethodInfo proxyMethod = type.GetMethod(nameof(AsyncMethodProxy), BindingFlags.Static | BindingFlags.Public);
        var proxyMethodWrapper = new MethodWrapper(type, proxyMethod);
        suite.Method = proxyMethodWrapper;

        TestMethod proxyTestMethod = _builder.BuildTestMethod(proxyMethodWrapper, suite, parms);
        proxyTestMethod.Name = method.Name;
        proxyTestMethod.parms.HasExpectedResult = false;

        return proxyTestMethod;
    }

    public static IEnumerator AsyncMethodProxy(IMethodInfo method, Test suite)
    {
        return AsyncSupport.RunAsEnumerator(() => (Task) method.Invoke(suite.Fixture));
    }
}

Helper class

public static class AsyncSupport
{
    public static IEnumerator RunAsEnumerator(Func<Task> task)
    {
        SynchronizationContext oldContext = SynchronizationContext.Current;
        var newContext = new EnumeratorSynchronizationContext();

        SynchronizationContext.SetSynchronizationContext(newContext);

        newContext.Post(async _ => 
        {
            try
            {
                await task();
            }
            catch (Exception e)
            {
                newContext.InnerException = e;
                throw;
            }
            finally
            {
                newContext.EndMessageLoop();
            }
        }, null);

        SynchronizationContext.SetSynchronizationContext(oldContext);

        return newContext.BeginMessageLoop();
    }
}

Sync. context from here: https://social.msdn.microsoft.com/Forums/en-US/163ef755-ff7b-4ea5-b226-bbe8ef5f4796/is-there-a-pattern-for-calling-an-async-method-synchronously?forum=async

public class EnumeratorSynchronizationContext : SynchronizationContext
{
    private bool _done;
    public Exception InnerException { get; set; }
    private readonly AutoResetEvent _workItemsWaiting = new AutoResetEvent(false);

    private readonly Queue<Tuple<SendOrPostCallback, object>> _items =
        new Queue<Tuple<SendOrPostCallback, object>>();

    public override void Send(SendOrPostCallback d, object state)
    {
        throw new NotSupportedException("We cannot send to our same thread");
    }

    public void EndMessageLoop()
    {
        Post(_ => _done = true, null);
    }

    public override void Post(SendOrPostCallback d, object state)
    {
        lock (_items)
        {
            _items.Enqueue(Tuple.Create(d, state));
        }
        _workItemsWaiting.Set();
    }

    public IEnumerator BeginMessageLoop()
    {
        while (!_done)
        {
            Tuple<SendOrPostCallback, object> task = null;
            lock (_items)
            {
                if (_items.Count > 0)
                {
                    task = _items.Dequeue();
                }
            }
            if (task != null)
            {
                task.Item1(task.Item2);
                if (InnerException != null)
                {
                    throw InnerException;
                }
            }

            yield return null;
        }
    }

    public override SynchronizationContext CreateCopy()
    {
        return this;
    }
}

Usage example

Works exactly the way you think it does. Since TestAsyncAttribute converts the task method to the IEnumerator, Task.Delay (and any other task) does not block the current thread, so the player loop keeps running.

public class YourTestFixture
{
    [TestAsync]
    public async Task YourAsyncTest()
    {
        await Task.Delay(1000);
        Debug.Log("Hi!");
    }
}
3 Likes

Inspired by this, I'm thinking of making an OpenUPM package that implements a similar attribute. I can add a credit line somewhere if you want :)

My idea would be to probably extend it with things like AsyncTestCaseAttribute.

Whipped up this GitHub repo quickly: https://github.com/sbergen/UniAsyncTest

1 Like