如何对具有 await Task.Delay 的组件进行单元测试,而无需真正等待。
例如,
public void Retry()
{
// doSomething();
if(fail)
await Task.Delay(5000);
}
我需要测试失败分支,但我不希望我的测试等待 5 秒。
任务并行库中是否有类似rx 虚拟基于时间的调度?
如何对具有 await Task.Delay 的组件进行单元测试,而无需真正等待。
例如,
public void Retry()
{
// doSomething();
if(fail)
await Task.Delay(5000);
}
我需要测试失败分支,但我不希望我的测试等待 5 秒。
任务并行库中是否有类似rx 虚拟基于时间的调度?
计时器只是另一种形式的外部依赖。唯一的区别是它依赖于时钟而不是数据库。和任何其他外部依赖一样,它使测试变得困难;因此,答案是改进设计,直到它易于测试。
遵循依赖注入原则建议您应该将计时器传递给对象的构造函数,这使您能够注入测试存根而不是依赖于真正的计时器。
请注意这可以如何改进您的设计。在许多情况下,超时期限因呼叫者而异。这将建立超时期限的责任转移到正在等待它的应用程序层。那是应该了解它愿意等待多长时间的层。
任务并行库中是否有类似 rx 虚拟基于时间的调度?
不,那里没有。您的选择是定义一个可以使用测试存根实现的“计时器服务”,或者使用 Microsoft Fakes 来拦截对Task.Delay
. 我更喜欢后者,但它只是 VS Ultimate 的一个选项。
1) 定义您自己的Task.Delay 实现。
public static class TaskEx
{
private static bool _shouldSkipDelays;
public static Task Delay(TimeSpan delay)
{
return _shouldSkipDelays ? Task.FromResult(0) : Task.Delay(delay);
}
public static IDisposable SkipDelays()
{
return new SkipDelaysHandle();
}
private class SkipDelaysHandle : IDisposable
{
private readonly bool _previousState;
public SkipDelaysHandle()
{
_previousState = _shouldSkipDelays;
_shouldSkipDelays = true;
}
public void Dispose()
{
_shouldSkipDelays = _previousState;
}
}
}
2) 在代码中的任何地方都使用 TaskEx.Delay 而不是 Task.Delay。
3) 在您的测试中使用 TaskEx.SkipDelays:
[Test]
public async Task MyTest()
{
using (TaskEx.SkipDelays())
{
// your code that will ignore delays
}
}
基于 alexey anwser 为 Task.Delay 创建一个包装器,这里是如何创建一个使用 Reactive Extensions 的 IScheduler 的 Task.Delay,因此您可以使用虚拟时间来测试延迟:
using System;
using System.Reactive.Linq;
using System.Reactive.Threading.Tasks;
using System.Threading;
using System.Threading.Tasks;
public static class TaskEx
{
public static Task Delay(int millisecondsDelay, CancellationToken cancellationToken = default(CancellationToken))
{
#if TEST
return Observable.Timer(TimeSpan.FromMilliseconds(millisecondsDelay), AppContext.DefaultScheduler).ToTask(cancellationToken);
#else
return Task.Delay(millisecondsDelay, cancellationToken);
#endif
}
}
如果您不进行单元测试,这将使用编译符号来完全避免 Rx。
AppContext 只是一个引用您的调度程序的上下文对象。在您的测试中,您可以设置AppContext.DefaultScheduler = testScheduler
延迟将由虚拟时间调度程序引起。
不过有一点需要注意。TestScheduler 是同步的,所以你不能启动一个任务并在里面使用TaskEx.Delay,因为调度器会在任务被调度之前提前。
var scheduler = new TestScheduler();
AppContext.DefaultScheduler = scheduler;
Task.Run(async () => {
await TaskEx.Delay(100);
Console.Write("Done");
});
/// this won't work, Task.Delay didn't run yet.
scheduler.AdvanceBy(1);
相反,您需要始终使用 启动任务Observable.Start(task, scheduler)
,因此任务按顺序运行:
var scheduler = new TestScheduler();
AppContext.DefaultScheduler = scheduler;
Observable.Start(async () => {
await TaskEx.Delay(100);
Console.Write("Done");
}, scheduler);
/// this runs the code to schedule de delay
scheduler.AdvanceBy(1);
/// this actually runs until the delay is complete
scheduler.AdvanceBy(TimeSpan.FromMilliseconds(100).Ticks);
这当然更棘手,所以我不会在使用 Task.Delay 的任何地方都使用它。但是有一些特定的代码片段,延迟会改变应用程序的行为,您需要对其进行测试,因此它对这些特殊情况很有用。
正如 John Deters 所提到的,这是对计算机时钟的外部依赖(就像您需要获取当前时间一样,尽管调用 DateTime.UtcNow 很容易,但它仍然是一个依赖项)。
但是,这些是特殊依赖项,因为您可以提供始终有效的默认值(Task.Delay 或 DateTime.UtcNow)。因此,您可以拥有一个属性来执行此操作:
private Func<int, Task> delayer = millisecondsDelay => Task.Delay(millisecondsDelay);
public Func<int, Task> Delayer
{
get { return delayer; }
set { delayer = value ?? (millisecondsDelay => Task.Delay(millisecondsDelay)) }
}
使用它,您可以在测试中替换对 Task.Delay 的调用。
sut.Delayer = _ => Task.CompletedTask;
当然,这样做的干净方法是声明一个接口并通过您的构造函数获取它。
您可以添加测试接缝。这很简单,但不是很干净。您可能会或可能不会使用它,这取决于您是否相信其他人不会滥用它:-)
将公共静态属性添加到您的类
/// /// 延迟,仅用于测试的更改 /// public static int DelayMs { get; 放; } = 1000;
在您的代码中使用它
await Task.Delay(DelayMs, cancellationToken);
在您的单元测试中,将其更改为 0
MyClass.DelayMs = 0;