CurrentThreadTaskScheduler does not finish Synchro

2019-05-19 00:00发布

问题:

I try to write a Unit-Test for a View Model, but i got stuck when trying to verify a ICommand calls a asynchronous method twice.

i use Moq for my dependencies. I set up the async Method like this.

this.communicationServiceFake
     .Setup(x => x.WriteParameterAsync(It.IsAny<string>(), It.IsAny<object>()))
     .ReturnsAsyncIncomplete();

The Extension ReturnsAsyncIncomplete does not return instantly at the await keyword, basically something like that found here: Async/Await and code coverage

I use a own TaskSheduler to ensure that the Methods are compete before the Task.Factory.StartNew returns.

Task.Factory.StartNew(() => viewModel.Command.Execute(null), 
    CancellationToken.None, TaskCreationOptions.None, new CurrentThreadTaskScheduler ());

Basically the CurrentThreadTaskScheduler comes from here: Wait until all Task finish in unit test and does look like this:

public class CurrentThreadTaskScheduler : TaskScheduler
{
    protected override void QueueTask(Task task)
    {
        this.TryExecuteTask(task);
    }

    protected override bool TryExecuteTaskInline(Task task, bool wasPreviouslyQueued)
    {
        return this.TryExecuteTask(task);
    }

    protected override IEnumerable<Task> GetScheduledTasks()
    {
        yield break;
    }
}

the Command does call the following code:

await this.communicationService.WriteParameterAsync("Parameter1", true);
await this.communicationService.WriteParameterAsync("Parameter2", true);

Then the verification:

  this.communicationServiceFake
       .Verify(t => t.WriteParameterAsync("Parameter1", true), Times.Once);
  this.communicationServiceFake
       .Verify(t => t.WriteParameterAsync("Parameter2", true), Times.Once);

sometimes it says the 2nd call did not happen. If i replace my Task.Factory.StartNew with a ThreadSleep to ensure everything finished, all works fine even thought it does note seem right to delay my unit tests unnecessarily.

Why does my CurrentThreadTaskScheduler allow the Task.Factory.StartNew to return before my Command.Execute finished?

回答1:

The GetIncompleteTask extension method in the linked blog post uses Task.Run, hence introducing another thread (and a race condition).

The CurrentThreadTaskScheduler will only work for async void methods if all tasks are already completed, which is exactly what you're avoiding with ReturnsAsyncIncomplete.

Here's what's happening:

  1. Execute runs in CurrentThreadTaskScheduler. It awaits an incomplete task that is running on the threadpool. At this point, the StartNew task is complete.
  2. The thread pool thread finishes the incomplete task, and executes its continuations on the current thread (the thread pool thread).

What you're trying to do is unit test an async void method with full coverage (i.e., asynchronously). This is certainly not easy.

Recommended solution

Use some form of an async-aware ICommand such as the ones I describe in my MSDN article. Then you can just use await viewModel.Command.ExecuteAsync(null) in your unit test and not mess around with custom task schedulers at all.

Also, the asynchronous mocking can really be simplified; there's no need for a custom awaiter because the .NET framework already has one: Task.Yield. So you can replace all that code with just a single extension method (untested):

public static IReturnsResult<TMock> ReturnsIncompleteAsync<TMock, TResult>(this IReturns<TMock, Task<TResult>> mock, TResult value) where TMock : class
{
  return mock.Returns(async () =>
  {
    await Task.Yield();
    return value;
  });
}

Keeping async void

If you really want to keep your existing ICommand implementations, and want to unit test async void methods, and want full code coverage, then you really need a custom SynchronizationContext instead of a custom TaskScheduler. The easiest way to do this is to install my AsyncEx NuGet package and use AsyncContext:

// Blocks the current thread until all continuations have completed.
AsyncContext.Run(() => viewModel.Command.Execute(null));

Also, I recommend you use the Task.Yield approach described above instead of the custom-awaitable-on-a-thread-pool.