我刚刚看过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发布日期前完成的。
答案 0 :(得分:4)
在这种情况下,SetResult
正在同步(直接)执行其继续。这是由于一些未记录的细节:
await
将使用TaskContinuationOption.ExecuteSynchronously
标志安排其延续。 When I first discovered this behavior,我reported it as a bug。虽然我仍然认为异步延续不那么令人惊讶,但有一个有效的效率论据支持同步执行。await
捕获SynchronizationContext
时,如果当前SynchronizationContext
与捕获的SynchronizationContext
(引用相等)相同,则允许同步延续。同样,这是出于性能原因; SyncCtx实例上的相等性没有明确定义,但这在现实世界中运行良好。因此,您会看到此行为,因为在SetResult
行,SynchronizationContext.Current
设置为await
中MethodToBeTested
所捕获的同一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
,因此您没有看到任何差异