You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何使用FakeTimeProvider测试阻塞式轮询代码?

问题:FakeTimeProvider与轮询方法并行推进时间的同步问题

我在集成测试中使用Microsoft.Extensions.Time.Testing.FakeTimeProvider控制时间流逝,验证轮询方法WaitTillWithTimeout的超时逻辑——这个方法会每隔1小时轮询一次,直到满足条件或触发超时。直接调用TimeProvider.Advance(...)无法和轮询逻辑并行执行,我尝试编写了TimeAdvancer异步推进时间,但它经常在WaitTillWithTimeout启动前就执行完毕,导致测试失效。


核心代码

测试类与轮询方法

using Microsoft.Extensions.Time.Testing;

namespace TestFramework.Tests.Integration
{
    public class Tests
    {
        FakeTimeProvider timeProvider = new();

        [Test]
        public void WaitTillWithTimeout_WhenConditionAlwaysFalse_WillTimeoutAsDefined()
        {
            // Arrange
            AutomaticCalendar serviceUsingTimeprovider = new(timeProvider);
            var beforeCall = timeProvider.GetTimestamp();

            try
            {
                // Act
                WaitTillWithTimeout(serviceUsingTimeprovider, x => false, timeout: TimeSpan.FromDays(365));
                // TODO: advance the timeProvider to pass along all the days of the year; timeProvider.Advance(TimeSpan.FromDays(365)); does not work!
            }
            catch (TimeoutException)
            {
                // Assert
                Assert.That(timeProvider.GetElapsedTime(beforeCall), Is.EqualTo(TimeSpan.FromDays(365)));
            }
        }

        [Test]
        public void WaitTillWithTimeout_WhenConditionTrueWithin7Days_WillTimeoutWithin7Days()
        {
            // Arrange
            AutomaticCalendar serviceUsingTimeprovider = new(timeProvider);
            var beforeCall = timeProvider.GetTimestamp();

            // Act
            WaitTillWithTimeout(serviceUsingTimeprovider, x => x.DayOfWeek == "Friday", timeout: TimeSpan.FromDays(365));
            // TODO: advance the timeProvider to pass along all the days of the year; timeProvider.Advance(TimeSpan.FromDays(365)); does not work!

            // Assert
            Assert.That(timeProvider.GetElapsedTime(beforeCall), Is.LessThan(TimeSpan.FromDays(7)));
            // TODO Assert that the method does not hang forever
        }

        private void WaitTillWithTimeout(AutomaticCalendar testCalendar, Predicate<AutomaticCalendar> computation, TimeSpan timeout)
        {
            var pollingPeriod = TimeSpan.FromHours(1);

            var start = timeProvider.GetTimestamp();
            Exception? lastException = null;
            bool result = false;
            while (timeProvider.GetElapsedTime(start) <= timeout)
            {
                try
                {
                    result = computation(testCalendar);
                }
                catch (System.Exception error)
                {
                    lastException = error;
                }

                if (result)
                {
                    return;
                }
                timeProvider.Delay(pollingPeriod).Wait();
            }

            if (!result)
            {
                throw new TimeoutException($"timeout after {timeout}", lastException);
            }
        }

        public class AutomaticCalendar
        {
            public string? DayOfWeek { get; private set; }

            internal AutomaticCalendar(TimeProvider timeProvider)
            {
                // everyhour update the day of the week
                timeProvider.CreateTimer(state => DayOfWeek = timeProvider.GetUtcNow().DayOfWeek.ToString(), null, TimeSpan.Zero, TimeSpan.FromHours(1));
            }
         }
    }
}

尝试的TimeAdvancer实现

// increment the FakeTimeProvider slowly until 3 seconds are passed with increments of 100ms over a period of 1 sec
using(var advancer = TimeAdvancer(_timeprovider, advance:TimeSpan.FromSeconds(3), increment:TimeSpan.FromMilliseconds(100), realtime: TimeSpan.FromSeconds(1)))
{
   WaitTillWithTimeout(serviceUsingTimeprovider, x => false, timeout: TimeSpan.FromDays(365));           
}
private static IDisposable TimeAdvancer(FakeTimeProvider timeProvider, TimeSpan advance, TimeSpan increment, TimeSpan realtime)
{
    var advancerFunc = () =>
    {
        for (var time = TimeSpan.Zero; time < advance; time += increment)
        {
            timeProvider.Advance(increment);
        }
    };

    var cancellationTokenSource = TimeProvider.System.CreateCancellationTokenSource(realtime);
    Task.Run(advancerFunc, cancellationTokenSource.Token);

    return new MyDisposable(() => {
        cancellationTokenSource.Cancel();
    });
}

private class MyDisposable : IDisposable
{
    private readonly Action _disposeAction;
    public MyDisposable(Action disposeAction) => _disposeAction = disposeAction;
    public void Dispose() => _disposeAction();
}

解决方案

要解决同步问题,核心是让时间推进逻辑和轮询方法同时启动,并确保推进过程中轮询逻辑有机会执行。以下是两种可靠的实现方式:

方式一:用信号量同步启动

在TimeAdvancer中加入信号量,确保轮询方法启动后再开始推进时间:

private class SemaphoreDisposable : IDisposable
{
    public SemaphoreSlim StartSemaphore { get; }
    private readonly CancellationTokenSource _cts;

    public SemaphoreDisposable(CancellationTokenSource cts)
    {
        StartSemaphore = new SemaphoreSlim(0, 1);
        _cts = cts;
    }

    public void Dispose()
    {
        _cts.Cancel();
        StartSemaphore.Dispose();
        _cts.Dispose();
    }
}

private static IDisposable TimeAdvancer(FakeTimeProvider timeProvider, TimeSpan advance, TimeSpan increment, TimeSpan realtime)
{
    var cts = TimeProvider.System.CreateCancellationTokenSource(realtime);
    var disposable = new SemaphoreDisposable(cts);

    var advancerFunc = async () =>
    {
        // 等待轮询方法启动信号
        await disposable.StartSemaphore.WaitAsync(cts.Token);
        
        var totalAdvance = TimeSpan.Zero;
        while (totalAdvance < advance && !cts.Token.IsCancellationRequested)
        {
            timeProvider.Advance(increment);
            totalAdvance += increment;
            // 给轮询逻辑留执行时间
            await Task.Delay(1, cts.Token);
        }
    };

    Task.Run(advancerFunc, cts.Token);
    return disposable;
}

使用时先获取advancer,释放信号量后再调用轮询方法:

using(var advancer = (SemaphoreDisposable)TimeAdvancer(timeProvider, advance:TimeSpan.FromDays(365), increment:TimeSpan.FromHours(1), realtime: TimeSpan.FromSeconds(10)))
{
    // 释放信号,让时间推进逻辑启动
    advancer.StartSemaphore.Release();
    WaitTillWithTimeout(serviceUsingTimeprovider, x => false, timeout: TimeSpan.FromDays(365));           
}

方式二:测试中直接并行执行

不需要单独的TimeAdvancer,用Task.WhenAll同时启动轮询和时间推进:

[Test]
public void WaitTillWithTimeout_WhenConditionAlwaysFalse_WillTimeoutAsDefined()
{
    // Arrange
    AutomaticCalendar serviceUsingTimeprovider = new(timeProvider);
    var beforeCall = timeProvider.GetTimestamp();
    var timeout = TimeSpan.FromDays(365);
    var pollingPeriod = TimeSpan.FromHours(1);

    // 启动轮询任务
    Task pollingTask = Task.Run(() => 
    {
        try
        {
            WaitTillWithTimeout(serviceUsingTimeprovider, x => false, timeout);
        }
        catch (TimeoutException)
        {
            // 预期异常,无需处理
        }
    });

    // 并行推进时间,步长匹配轮询周期
    Task advanceTask = Task.Run(() =>
    {
        var totalAdvance = TimeSpan.Zero;
        while (totalAdvance < timeout && !pollingTask.IsCompleted)
        {
            timeProvider.Advance(pollingPeriod);
            totalAdvance += pollingPeriod;
            // 避免线程调度抢占,给轮询逻辑执行机会
            Thread.Sleep(1);
        }
    });

    // 等待两个任务完成
    Task.WaitAll(pollingTask, advanceTask);

    // Assert
    Assert.That(timeProvider.GetElapsedTime(beforeCall), Is.EqualTo(timeout));
}

关键说明

  • FakeTimeProvider的Delay().Wait()是同步阻塞的,必须推进时间才能让它继续执行
  • 时间推进的步长建议和轮询周期一致,精准模拟真实场景的轮询间隔
  • 加入1ms的真实延迟是为了避免线程调度问题,确保轮询逻辑有机会处理时间变化

内容的提问来源于stack exchange,提问作者Willem Jan Gerritsen

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.18 13:58:10