如何使用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
相关产品推荐
相关产品推荐

