I'm trying to cover some logic with unit tests and have a problem with Task.ContinueWith method. The problem is that I have an important logic in the ContinueWith task. The ContinueWith task will be executed after the specified task, but there is no guarantee it will be executed immediately. So as a result, my test sometimes fails and sometimes succeeds.

There is my code:

The method:

public IPromise CreateFromTask(Task task)
{
    var promise = new ControllablePromise(_unityExecutor);
    task.ContinueWith(t =>
    {
        if (t.IsCanceled)
            Debug.LogWarning("Promises doesn't support task canceling");
        if (t.Exception == null)
            promise.Success();
        else
        {
            Debug.Log(t.Exception.InnerException);
            promise.Fail(t.Exception.InnerException);
        }
    }, TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.PreferFairness);
    return promise;
}

The test:


private PromiseFactory CreateFactory(out IUnityExecutor unityExecutor)
{
    unityExecutor = Substitute.For<IUnityExecutor>();
    return new PromiseFactory(unityExecutor);
}

[Test]
public void CreateFromTask_FailedTask_OnFailExecuted()
{
    // Arrange
    var factory = CreateFactory(out var unityExecutor);
    var testException = new Exception("Test exception");
    void TaskAction()
    {
        throw testException;
    }

    Exception failException = null;
    void FailCallback(Exception exception)
    {
        failException = exception;
    }
    
    unityExecutor.ExecuteOnFixedUpdate(Arg.Do<Action>(x => x?.Invoke()));

    // Act
    Debug.Log("About to run a task");
    var task = Task.Run(TaskAction);
    Debug.Log("The task run");
    var promise = factory.CreateFromTask(task);
    Debug.Log("Promise created");
    Task.WaitAny(task.ContinueWith(t => { }, 
        TaskContinuationOptions.PreferFairness | TaskContinuationOptions.ExecuteSynchronously));
    Debug.Log("Task awaited");
    promise.OnFail(FailCallback);
    
    // Assert
    Assert.AreEqual(testException, failException);
}

Log output is next: enter image description here

As you can see, I've already tried to use TaskContinuationOptions.ExecuteSynchronously and TaskContinuationOptions.PreferFairness to fix this, but it didn't help. I was very surprised that even with these options, my test didn't work.

If it is important I'm doing all of this in Unity3d with its standard test framework.

Expected result that the error always should be logged before "Task awaited" log.


Solution 1:

I'd rather somehow wait for the continuation task, which is now abandoned inside the CreateFromTask method. But, if it's impossible:

1. If the Promise supports multiple subscriptions, you can create an extension method in your Unit Tests project as shown below:

public static class PromiseExtensions
{
     public static Task AsTask(this IPromise promise)
     {
         var tcs = new TaskCompletionSource();

         promise.OnFail(exception => { tcs.TrySetResult(); });
         promise.OnSuccess(() => { tcs.TrySetResult(); });

         return tcs.Task;
     }
}

[Test]
public void CreateFromTask_FailedTask_OnFailExecuted()
{
    // Arrange
    var factory = CreateFactory(out var unityExecutor);
    var testException = new Exception("Test exception");
    void TaskAction()
    {
        throw testException;
    }

    Exception failException = null;
    void FailCallback(Exception exception)
    {
        failException = exception;
    }

    unityExecutor.ExecuteOnFixedUpdate(Arg.Do<Action>(x => x?.Invoke()));

    // Act
    var task = Task.Run(TaskAction);
    var promise = factory.CreateFromTask(task);
    Task.WaitAny(promise.AsTask(), Task.Delay(TimeSpan.FromSeconds(1)));
    promise.OnFail(FailCallback);
        
    // Assert
    Assert.AreEqual(testException, failException);
}

2. If the Promise doesn't support multiple subscriptions, just wait until the FailCallback is executed:

[Test]
public void CreateFromTask_FailedTask_OnFailExecuted()
{
    // Arrange
    var factory = CreateFactory(out var unityExecutor);
    var testException = new Exception("Test exception");
    void TaskAction()
    {
        throw testException;
    }

    TaskCompletionSource failCallbackCompletionSource = new TaskCompletionSource();
    Exception failException = null;
    void FailCallback(Exception exception)
    {
        failException = exception;
        failCallbackCompletionSource.TrySetResult();
    }

    unityExecutor.ExecuteOnFixedUpdate(Arg.Do<Action>(x => x?.Invoke()));

    // Act
    var task = Task.Run(TaskAction);
    var promise = factory.CreateFromTask(task);
    promise.OnFail(FailCallback);
    Task.WaitAny(failCallbackCompletionSource.Task, Task.Delay(TimeSpan.FromSeconds(1)));

    // Assert
    Assert.AreEqual(testException, failException);
}