4

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.

Nikolai
  • 656
  • 6
  • 19

3 Answers3

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);
}
Yevhen Cherkes
  • 626
  • 7
  • 10
  • It is really good!! But it'll be better to use SemaphoreSlim instead of TaskCompletitionSource, cause performance reasons. And one second timeout is too many for unit tests IMHO. – Nikolai Jan 27 '22 at 20:55
0

You don't need to create another ContinueWith task, you just need to wait for the previously created task.

public IPromise CreateFromTask(ref Task task)
{
    var promise = new ControllablePromise(_unityExecutor);

    /* Replace the task with the new one */
    task = 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);
        }
    });
    return promise;
}

// Act
Debug.Log("About to run a task");
var task = Task.Run(TaskAction);
Debug.Log("The task run");
var promise = factory.CreateFromTask(ref task);
Debug.Log("Promise created");
Task.WaitAny(task); /* Waiting for the task */
Debug.Log("Task awaited");
promise.OnFail(FailCallback);

If you want to wait for multitasks

// Act
Debug.Log("About to run a task");
var task = Task.Run(TaskAction);
var task2 = Task.Run(TaskAction);
Debug.Log("The task run");
var promise = factory.CreateFromTask(ref task);
Debug.Log("Promise created");
Task.WaitAll(task, task2.ContinueWith(t => { })); /* Waiting for all tasks */
Debug.Log("Task awaited");
promise.OnFail(FailCallback);
shingo
  • 18,436
  • 5
  • 23
  • 42
  • I'd like not to violate an incapsulation principle. – Nikolai Jan 12 '22 at 07:49
  • What is the encapsulation unit in your question? – shingo Jan 12 '22 at 08:42
  • Let me clarify what I mean. I'm not going to change logic to make test works. The logic of my classes are completely independent of tests. The classes just should do their work, and accept the minimum amout of data to do it. And also returns minimum amount of data to clients to handle its work correctly. When you are developing an abstraction layer, you should no think, about an implementation, or how you will test it. And the method signature is a part of the abstraction layer. The "ref var" solution, that you provide, depends on internal implemetation, so it doesn't suit me. – Nikolai Jan 12 '22 at 10:56
  • I want to know which part is the logic. To know whether if a task is finished, you have to get a signal from the task. Usually in .net an asynchronous operation returns a Task object, but your CreateFromTask method returns an IPromise object, is it the same thing from JS? If so, it should contain a `then` method, which may be used to continue the test. – shingo Jan 12 '22 at 11:33
  • Sorry for long answer. The IPromise is my class, and in my code I’m writing unit tests for PromiseFactory. In your answer you recommend not to create ContinueWith task, but your code are still creating it? What do you mean? By “logic” I mean not unit tests. – Nikolai Jan 22 '22 at 23:05
  • I mean not to create 2 ContinueWith tasks, because they are irrelative, you can't know when a ContinueWith task is completed by creating another task. – shingo Jan 23 '22 at 06:49
0

Random guess, when using ContinueWith and Unity in Mono Behaviours its important to keep it on the main thread. You can use the following snippet. Better description in this firebase article.

var taskScheduler = TaskScheduler.FromCurrentSynchronizationContext(); 
task.ContinueWith(t =>  { // perform work here }, taskScheduler); 
  • `its important to keep it on the main thread.` -> only if you want to access/modify the scene or assets directly ;) – derHugo Jan 14 '22 at 10:32