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

时间:2017-03-20 08:28:25

标签: c# unit-testing async-await

我刚刚看过Jon Skeet的视频课程,他谈到了单元测试异步方法。它是在一个付费网站上,但我发现了一些类似于他所说的内容,在他的book中(只需按Ctrl + F“15.6.3。单元测试异步代码”)。

完整的代码可以在his 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;
    }
}

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);
        }
    }
}

输出结果为:

== START ==
Before login
Before advance
After login
After advance
== END ==

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

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

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

有条件的信息

通过我的研究,我偶然发现this answer。为了尝试它,我将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());
}

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

== START ==
Before login
Post()
Before advance
PumpAll()
Login()
After login
After advance
== END ==

因此,在Jon Skeet使用TaskCompletionSource的情况下,他的ManuallyPumpedSynchronizationContext创作不是必需的吗?

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

3 个答案:

答案 0 :(得分:4)

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

  1. await将使用TaskContinuationOption.ExecuteSynchronously标志安排其延续。 When I first discovered this behavior,我reported it as a bug。虽然我仍然认为异步延续不那么令人惊讶,但有一个有效的效率论据支持同步执行。
  2. await捕获SynchronizationContext时,如果当前SynchronizationContext与捕获的SynchronizationContext(引用相等)相同,则允许同步延续。同样,这是出于性能原因; SyncCtx实例上的相等性没有明确定义,但这在现实世界中运行良好。
  3. 因此,您会看到此行为,因为在SetResult行,SynchronizationContext.Current设置为awaitMethodToBeTested所捕获的同一SyncCtx。 / p>

    一个更现实的例子是在调用被测系统后清除当前的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
    ...
    

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

    或者,当然,您可以将SetResult包裹在Task.Run中:

    Task.Run(() => loginPromise.SetResult(null)).Wait();
    

    强制继续线程池线程(没有SyncCtx),因此延续将 调用Post

    作为最后一点,您可能希望使用AsyncEx库中的AsyncContext类型;它是一个更加充实的自定义SynchronizationContext,它将自己与特定的线程联系起来。我最初编写AsyncContext用于单元测试。当SUT具有异步代码时,通常需要SyncCtx。事实上,xUnit 在测试框架中内置了

答案 1 :(得分:2)

我不会讨论这段代码的目标是什么,因为我没有在您发布的github链接上阅读本书或整个代码。我将使用您在当前问题中发布的代码。

我认为在您提供的代码中,ManuallyPumpedSynchronizationContext未被使用(无论您在何处运行它:在控制台应用程序,单元测试,UI应用程序等)。它的Post方法不会被调用,因为没有同步上下文切换。它通常表示await的延续Post将被捕获到同步上下文,这通常是正确的,但如果在等待方法完成之后,您仍然在同一个同步上下文中 - 没有理由发布任何内容 - 您处于相同的环境中并且可以继续。这就是这里发生的事情。当你打电话:

loginPromise.SetResult(null);

目前的背景仍为ManuallyPumpedSynchronizationContext

但是,如果你改变它:

SynchronizationContext.SetSynchronizationContext(null);
loginPromise.SetResult(null);

现在当Login()完成时,您不再处于捕获的上下文中,因此延续将确实Post,因此延迟将被延迟,直到您调用PumpAll。< / p>

更新:请参阅@StephenCleary以获得有关此行为的更完整说明(还有一个因素未在我的回答中提及)。

答案 2 :(得分:1)

因为您在控制台应用程序中执行代码 控制台应用程序没有同步上下文SynchronizationContext.Current将始终为空。

ManuallyPumpedSynchronizationContext的目的是“保存”执行测试方法的同步上下文,并将已完成任务提供的结果“泵”到保存的上下文中。

在控制台应用程序中,保存的上下文为null,因此您没有看到任何差异