如何对包含Task.Delay的代码进行单元测试?

17

如何对具有等待Task.Delay的组件进行单元测试而无需实际等待。

例如,

public void Retry()
{
    // doSomething();
    if(fail)
       await Task.Delay(5000);
}

我需要测试故障分支,但我不想让我的测试等待5秒钟。

在任务并行库中是否有类似rx基于虚拟时间的调度的东西可用?

7个回答

14
一个计时器就是另一种外部依赖。与数据库不同,它依赖于时钟。和其他外部依赖一样,它会使测试变得困难;因此,答案是改进设计,直到易于测试。
遵循依赖注入原则建议您应该在对象的构造函数中传递计时器,这样可以注入一个测试存根而不是依赖真实计时器。
注意这如何改善您的设计。在许多情况下,超时时间基于调用者而异。这将负责确定超时期限的责任移交给了正在等待它的应用程序层。那就是应该了解需要等待多长时间的层。

6

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

我认为这是最好的选择,但可能有更好的实现方式。如果您使用 Rx,您也可以使用 IScheduler.Schedule(Timespan) 实现此类。这将允许您使用 Rx 测试调度程序来测试超时(如果需要)。它们的实现基本上是相同的。 - Natan

5

在任务并行库中是否有类似于rx虚拟时间调度的东西呢?

很遗憾,没有。你的选择是要么定义一个“计时器服务”,可以用测试桩实现,要么使用Microsoft Fakes截取对Task.Delay的调用。我更喜欢后者,但它只适用于VS Ultimate。


你能提供一些更详细的信息,关于如何完成后一种选项吗? - Serge Intern
我还没有写关于它的博客文章,但是这里有一些代码 - Stephen Cleary
你是如何添加对mscorlib.dll的引用,以便创建虚拟对象的? - Choco
1
@Choco:mscorlib 的引用会自动添加。 - Stephen Cleary
好的,谢谢。TypeMock似乎有一种模拟静态调用的方法,但它看起来非常不专业,我宁愿测试速度慢一些也不要测试难以阅读 :) - nbilal
显示剩余4条评论

4

基于Alexey的回答,创建了一个Task.Delay的包装器。下面是如何创建一个使用响应式扩展(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的所有地方都使用它。但是有一些特定的代码片段,在其中延迟会改变应用程序的行为并需要进行测试,因此对于这些特殊情况非常有用。


赞!这绝对是正确的方法,因为它允许测试操作时间。 - Dan M

2
正如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;

当然,更加规范的做法是声明一个接口,并通过构造函数获取它。

1
你可以添加一个测试接缝。这很简单,但不是很干净。根据您是否相信他人不会误用它,您可能需要或不需要使用它 :-)
在您的类中添加一个公共静态属性
/// /// 延迟,仅供测试更改 /// public static int DelayMs { get; set; } = 1000;
在您的代码中使用它。
await Task.Delay(DelayMs, cancellationToken);

在你的单元测试中,将其更改为0。
MyClass.DelayMs = 0;

1
你可以考虑在测试中添加超时时间,如果等待太久,则让测试失败。
或者你可以考虑传入超时时间,或任何一种不同配置超时的方法来进行测试。
有一个关于异步和TDD的博客文章,另一个在这里,尽管它们更多地指出了异步代码可能出现的一般问题,而不是特别处理Task.Delay

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接