在Jon Skeet的"TimeMachine"异步单元测试框架中是否需要ManuallyPumpedSynchronizationContext?

vyr*_*yrp 7 c# unit-testing async-await

我刚看过Jon Skeet的视频课程,他在那里讨论了异步方法的单元测试.它是在付费网站上,但我在他的书中找到了类似于他所说的内容(只有Ctrl + F"15.6.3.单元测试异步代码").

完整的代码可以在他的github上找到,但为了我的问题,我已经简化了它(我的代码基本上是StockBrokerTest.CalculateNetWorthAsync_AuthenticationFailure_ThrowsDelayed()在内联TimeMachine和Advancer操作).

假设我们有一个类来测试失败的登录(没有单元测试框架来简化问题):

public static class LoginTest
{
    private static TaskCompletionSource<Guid?> loginPromise = new TaskCompletionSource<Guid?>();

    public static void Main()
    {
        Console.WriteLine("== START ==");

        // Set up
        var context = new ManuallyPumpedSynchronizationContext(); // Comment this
        SynchronizationContext.SetSynchronizationContext(context); // Comment this

        // Run method under test
        var result = MethodToBeTested();
        Debug.Assert(!result.IsCompleted, "Result should not have been completed yet.");

        // Advancing time
        Console.WriteLine("Before advance");
        loginPromise.SetResult(null);
        context.PumpAll(); // Comment this
        Console.WriteLine("After advance");

        // Check result
        Debug.Assert(result.IsFaulted, "Result should have been faulted.");
        Debug.Assert(result.Exception.InnerException.GetType() == typeof(ArgumentException), $"The exception should have been of type {nameof(ArgumentException)}.");

        Console.WriteLine("== END ==");
        Console.ReadLine();
    }

    private static async Task<int> MethodToBeTested()
    {
        Console.WriteLine("Before login");
        var userId = await Login();
        Console.WriteLine("After login");
        if (userId == null)
        {
            throw new ArgumentException("Bad username or password");
        }

        return userId.GetHashCode();
    }

    private static Task<Guid?> Login()
    {
        return loginPromise.Task;
    }
}
Run Code Online (Sandbox Code Playgroud)

执行的地方ManuallyPumpedSynchronizationContext是:

public sealed class ManuallyPumpedSynchronizationContext : SynchronizationContext
{
    private readonly BlockingCollection<Tuple<SendOrPostCallback, object>> callbacks;

    public ManuallyPumpedSynchronizationContext()
    {
        callbacks = new BlockingCollection<Tuple<SendOrPostCallback, object>>();
    }

    public override void Post(SendOrPostCallback callback, object state)
    {
        Console.WriteLine("Post()");
        callbacks.Add(Tuple.Create(callback, state));
    }

    public override void Send(SendOrPostCallback d, object state)
    {
        throw new NotSupportedException("Synchronous operations not supported on ManuallyPumpedSynchronizationContext");
    }

    public void PumpAll()
    {
        Tuple<SendOrPostCallback, object> callback;
        while(callbacks.TryTake(out callback))
        {
            Console.WriteLine("PumpAll()");
            callback.Item1(callback.Item2);
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

输出是:

== START ==
Before login
Before advance
After login
After advance
== END ==
Run Code Online (Sandbox Code Playgroud)

我的问题是:为什么我们需要ManuallyPumpedSynchronizationContext

为什么默认的SynchronizationContext不够用?Post()甚至不调用该方法(基于输出).我已经尝试评论标记为的行// Comment this,输出是相同的,并且断言通过.

如果我正确理解Jon Skeet在视频中所说的内容,那么SynchronizationContext.Post()当我们遇到一个await尚未完成的任务时,应该调用该方法.但这种情况并非如此.我错过了什么?

有条件的信息

通过我的研究,我偶然发现了这个答案.为了尝试它,我将Login()方法的实现更改为:

private static Task<Guid?> Login()
{
    // return loginPromise.Task;
    return Task<Guid?>.Factory.StartNew(
        () =>
        {
            Console.WriteLine("Login()");
            return null;
        },
        CancellationToken.None,
        TaskCreationOptions.None,
        TaskScheduler.FromCurrentSynchronizationContext());
}
Run Code Online (Sandbox Code Playgroud)

通过这种修改,Post()确实调用了该方法.输出:

== START ==
Before login
Post()
Before advance
PumpAll()
Login()
After login
After advance
== END ==
Run Code Online (Sandbox Code Playgroud)

所以用Jon Skeet的使用TaskCompletionSource,他的创作是ManuallyPumpedSynchronizationContext不是必须的?

注意:我认为我看到的视频是在C#5发布日期前完成的.

Ste*_*ary 4

在这种情况下,SetResult正在同步(直接)执行其延续。这是由于一些未记录的细节造成的:

  1. await将用该标志安排其延续TaskContinuationOption.ExecuteSynchronously当我第一次发现这种行为时,我将其报告为错误。虽然我仍然认为异步延续并不令人惊讶,但有一个有效的效率论点支持同步执行。
  2. await捕获 a时,如果当前实例与捕获的实例相同(引用相等),SynchronizationContext则它将允许同步延续。同样,这是出于性能原因;SyncCtx 实例上的相等性没有明确定义,但这在现实世界中效果很好。SynchronizationContextSynchronizationContext

因此,您看到此行为是因为在该SetResult行,被设置为由inSynchronizationContext.Current捕获的相同 SyncCtx 。awaitMethodToBeTested

更实际的示例是在调用被测系统后清除当前的 SyncCtx。因此,单元测试代码并不存在于 SyncCtx“内部”;它只为被测系统提供SyncCtx:

...
// Set up
var context = new ManuallyPumpedSynchronizationContext(); // Comment this
SynchronizationContext.SetSynchronizationContext(context); // Comment this

// Run method under test
var result = MethodToBeTested();
Debug.Assert(!result.IsCompleted, "Result should not have been completed yet.");

// Tear down SyncCtx.
SynchronizationContext.SetSynchronizationContext(null);

// Advancing time
...
Run Code Online (Sandbox Code Playgroud)

或者,您可以传递TaskCreationOptions.RunContinuationsAsynchronouslyTaskCompletionSource<T>构造函数。但是,请注意.NET Framework 中当前存在的此错误将阻止其在全桌面控制台应用程序上运行;它仅适用于 .NET Core 控制台应用程序。

或者,当然,您可以将其包装SetResultTask.Run

Task.Run(() => loginPromise.SetResult(null)).Wait();
Run Code Online (Sandbox Code Playgroud)

这会强制在线程池线程上继续(没有 SyncCtx),因此继续必须调用Post.

最后一点,您可能想使用AsyncContextAsyncEx 库中的我的类型;这是一种更加充实的习俗SynchronizationContext,将自己与特定的线索联系在一起。我最初编写AsyncContext是为了与单元测试一起使用。当 SUT 具有异步代码时,它通常需要 SyncCtx。事实上,xUnit在测试框架中提供了自己的内置权利。